snapshot

Michael Muré created

Change summary

api/graphql/graph/repository.generated.go  |  85 +++
api/graphql/graph/root.generated.go        |   2 
api/graphql/graph/root_.generated.go       |  11 
api/graphql/schema/repository.graphql      |   3 
api/http/git_serve_handler.go              | 331 ++++++++++++
cache/repo_cache.go                        |   1 
cache/repo_cache_common.go                 |  20 
cache/subcache.go                          |  47 +
commands/webui.go                          |   6 
webui2/package-lock.json                   | 659 ++++++++++++++++++++++++
webui2/package.json                        |   6 
webui2/src/components/content/Markdown.tsx |  35 +
webui2/src/index.css                       |  28 
webui2/src/pages/CodePage.tsx              |  68 ++
14 files changed, 1,277 insertions(+), 25 deletions(-)

Detailed changes

api/graphql/graph/repository.generated.go 🔗

@@ -19,6 +19,7 @@ import (
 
 type RepositoryResolver interface {
 	Name(ctx context.Context, obj *models.Repository) (*string, error)
+	LocalName(ctx context.Context, obj *models.Repository) (string, error)
 	AllBugs(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int, query *string) (*models.BugConnection, error)
 	Bug(ctx context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error)
 	AllIdentities(ctx context.Context, obj *models.Repository, after *string, before *string, first *int, last *int) (*models.IdentityConnection, error)
@@ -450,6 +451,50 @@ func (ec *executionContext) fieldContext_Repository_name(_ context.Context, fiel
 	return fc, nil
 }
 
+func (ec *executionContext) _Repository_localName(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Repository_localName(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Repository().LocalName(rctx, obj)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Repository_localName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Repository",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _Repository_allBugs(ctx context.Context, field graphql.CollectedField, obj *models.Repository) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Repository_allBugs(ctx, field)
 	if err != nil {
@@ -945,6 +990,8 @@ func (ec *executionContext) fieldContext_RepositoryConnection_nodes(_ context.Co
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
+			case "localName":
+				return ec.fieldContext_Repository_localName(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":
@@ -1147,6 +1194,8 @@ func (ec *executionContext) fieldContext_RepositoryEdge_node(_ context.Context,
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
+			case "localName":
+				return ec.fieldContext_Repository_localName(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":
@@ -1221,6 +1270,42 @@ func (ec *executionContext) _Repository(ctx context.Context, sel ast.SelectionSe
 				continue
 			}
 
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+		case "localName":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Repository_localName(ctx, field, obj)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			if field.Deferrable != nil {
+				dfs, ok := deferred[field.Deferrable.Label]
+				di := 0
+				if ok {
+					dfs.AddField(field)
+					di = len(dfs.Values) - 1
+				} else {
+					dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
+					deferred[field.Deferrable.Label] = dfs
+				}
+				dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
+					return innerFunc(ctx, dfs)
+				})
+
+				// don't run the out.Concurrently() call below
+				out.Values[i] = graphql.Null
+				continue
+			}
+
 			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 		case "allBugs":
 			field := field

api/graphql/graph/root.generated.go 🔗

@@ -1112,6 +1112,8 @@ func (ec *executionContext) fieldContext_Query_repository(ctx context.Context, f
 			switch field.Name {
 			case "name":
 				return ec.fieldContext_Repository_name(ctx, field)
+			case "localName":
+				return ec.fieldContext_Repository_localName(ctx, field)
 			case "allBugs":
 				return ec.fieldContext_Repository_allBugs(ctx, field)
 			case "bug":

api/graphql/graph/root_.generated.go 🔗

@@ -385,6 +385,7 @@ type ComplexityRoot struct {
 		AllIdentities func(childComplexity int, after *string, before *string, first *int, last *int) int
 		Bug           func(childComplexity int, prefix string) int
 		Identity      func(childComplexity int, prefix string) int
+		LocalName     func(childComplexity int) int
 		Name          func(childComplexity int) int
 		UserIdentity  func(childComplexity int) int
 		ValidLabels   func(childComplexity int, after *string, before *string, first *int, last *int) int
@@ -1856,6 +1857,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.Identity(childComplexity, args["prefix"].(string)), true
 
+	case "Repository.localName":
+		if e.complexity.Repository.LocalName == nil {
+			break
+		}
+
+		return e.complexity.Repository.LocalName(childComplexity), true
+
 	case "Repository.name":
 		if e.complexity.Repository.Name == nil {
 			break
@@ -2728,6 +2736,9 @@ type OperationEdge {
     """The name of the repository. Null for the default (unnamed) repository."""
     name: String
 
+    """The local directory name of the repository (basename only, no path)."""
+    localName: String!
+
     """All the bugs"""
     allBugs(
         """Returns the elements in the list that come after the specified cursor."""

api/graphql/schema/repository.graphql 🔗

@@ -2,6 +2,9 @@ type Repository {
     """The name of the repository. Null for the default (unnamed) repository."""
     name: String
 
+    """The local directory name of the repository (basename only, no path)."""
+    localName: String!
+
     """All the bugs"""
     allBugs(
         """Returns the elements in the list that come after the specified cursor."""

api/http/git_serve_handler.go 🔗

@@ -0,0 +1,331 @@
+// Git smart HTTP handler — serves git clone and push using native git
+// subprocesses (git-upload-pack / git-receive-pack --stateless-rpc).
+//
+// Security notes:
+//   - No shell is used; exec.Command receives explicit argument slices.
+//   - The subprocess environment is sanitised: variables that could redirect
+//     git's operations (GIT_DIR, GIT_EXEC_PATH, GIT_SSH, …) are stripped.
+//   - The repository path is resolved from our internal config, never from
+//     URL parameters or request body content.
+//   - Client stderr is captured and discarded; it is never forwarded to the
+//     HTTP response.
+//
+// Routes (registered on the /api/repos/{owner}/{repo} subrouter):
+//
+//	GET  /info/refs?service=git-{upload,receive}-pack  → capability advertisement
+//	POST /git-upload-pack                               → fetch / clone
+//	POST /git-receive-pack                              → push (blocked in read-only mode)
+
+package http
+
+import (
+	"bytes"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+
+	pktline "github.com/go-git/go-git/v5/plumbing/format/pktline"
+
+	"github.com/git-bug/git-bug/cache"
+)
+
+// GitServeHandler exposes the repository over git's smart HTTP protocol.
+type GitServeHandler struct {
+	mrc      *cache.MultiRepoCache
+	readOnly bool
+}
+
+func NewGitServeHandler(mrc *cache.MultiRepoCache, readOnly bool) *GitServeHandler {
+	return &GitServeHandler{mrc: mrc, readOnly: readOnly}
+}
+
+// ServeInfoRefs handles GET /info/refs — the capability advertisement step.
+// Runs `git {upload,receive}-pack --stateless-rpc --advertise-refs` and
+// prepends the required PKT-LINE service header.
+// For upload-pack the advertised refs are filtered to heads and tags only so
+// that cloners do not inadvertently fetch git-bug internal objects.
+func (h *GitServeHandler) ServeInfoRefs(w http.ResponseWriter, r *http.Request) {
+	service := r.URL.Query().Get("service")
+	if service != "git-upload-pack" && service != "git-receive-pack" {
+		http.Error(w, "unknown service", http.StatusForbidden)
+		return
+	}
+	if service == "git-receive-pack" && h.readOnly {
+		http.Error(w, "repository is read-only", http.StatusForbidden)
+		return
+	}
+
+	repoPath, err := h.repoPathFor(r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusNotFound)
+		return
+	}
+
+	// "git-upload-pack" → "upload-pack", "git-receive-pack" → "receive-pack"
+	subCmd := strings.TrimPrefix(service, "git-")
+
+	cmd := exec.CommandContext(r.Context(),
+		"git", subCmd, "--stateless-rpc", "--advertise-refs", repoPath)
+	cmd.Env = safeGitEnv()
+
+	out, err := cmd.Output()
+	if err != nil {
+		http.Error(w, "git advertisement failed", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
+
+	// PKT-LINE service header required by the smart HTTP protocol.
+	enc := pktline.NewEncoder(w)
+	if err := enc.EncodeString(fmt.Sprintf("# service=%s\n", service)); err != nil {
+		return
+	}
+	if err := enc.Flush(); err != nil {
+		return
+	}
+
+	// For upload-pack, filter out internal git-bug refs (refs/bugs/,
+	// refs/identities/, …) so cloners only receive source code objects.
+	if service == "git-upload-pack" {
+		_ = writeFilteredInfoRefs(w, out)
+	} else {
+		_, _ = w.Write(out)
+	}
+}
+
+// ServeUploadPack handles POST /git-upload-pack — serves a fetch or clone.
+// The request body is piped directly to `git upload-pack --stateless-rpc`.
+func (h *GitServeHandler) ServeUploadPack(w http.ResponseWriter, r *http.Request) {
+	repoPath, err := h.repoPathFor(r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusNotFound)
+		return
+	}
+
+	body, err := requestBody(r)
+	if err != nil {
+		http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+	defer body.Close()
+
+	cmd := exec.CommandContext(r.Context(),
+		"git", "upload-pack", "--stateless-rpc", repoPath)
+	cmd.Env = safeGitEnv()
+	cmd.Stdin = body
+
+	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
+	w.Header().Set("Cache-Control", "no-cache")
+	cmd.Stdout = w
+
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	// Errors after this point can't change the HTTP status (headers already
+	// committed on first write), so we just return silently.
+	_ = cmd.Run()
+}
+
+// ServeReceivePack handles POST /git-receive-pack — accepts a push.
+// Before running git, the PKT-LINE ref-update commands are parsed so that the
+// git-bug cache can be synchronised for any git-bug namespaces that were
+// updated.
+func (h *GitServeHandler) ServeReceivePack(w http.ResponseWriter, r *http.Request) {
+	if h.readOnly {
+		http.Error(w, "repository is read-only", http.StatusForbidden)
+		return
+	}
+
+	repoPath, err := h.repoPathFor(r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusNotFound)
+		return
+	}
+
+	body, err := requestBody(r)
+	if err != nil {
+		http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+	defer body.Close()
+
+	// Parse the PKT-LINE ref-update commands so we know which git-bug entities
+	// to resync after the push completes.  The full request body is
+	// reconstructed (commands + flush + packfile) for git's stdin.
+	updatedRefs, fullBody, err := parseReceivePackCommands(body)
+	if err != nil {
+		http.Error(w, "parsing receive-pack request: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	cmd := exec.CommandContext(r.Context(),
+		"git", "receive-pack", "--stateless-rpc", repoPath)
+	cmd.Env = safeGitEnv()
+	cmd.Stdin = fullBody
+
+	w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
+	w.Header().Set("Cache-Control", "no-cache")
+	cmd.Stdout = w
+
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		// Headers may already be committed; best-effort return.
+		return
+	}
+
+	h.syncAfterPush(r, updatedRefs)
+}
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+// repoPathFor returns the filesystem path of the repository referenced in the
+// request URL variables.  The path is always resolved from our internal
+// MultiRepoCache configuration — it is never derived from request content.
+func (h *GitServeHandler) repoPathFor(r *http.Request) (string, error) {
+	rc, err := repoFromPath(h.mrc, r)
+	if err != nil {
+		return "", err
+	}
+	return rc.GetPath(), nil
+}
+
+// syncAfterPush updates the git-bug in-memory cache for any refs that were
+// updated by the push.
+func (h *GitServeHandler) syncAfterPush(r *http.Request, refs []string) {
+	if len(refs) == 0 {
+		return
+	}
+	rc, err := repoFromPath(h.mrc, r)
+	if err != nil {
+		return
+	}
+	_ = rc.SyncLocalRefs(refs)
+}
+
+// writeFilteredInfoRefs re-encodes the raw PKT-LINE advertisement output from
+// git, keeping only HEAD and refs/heads/* and refs/tags/*.  The first line is
+// always forwarded unchanged because it carries the server capability list
+// (appended after a NUL byte).
+func writeFilteredInfoRefs(w io.Writer, raw []byte) error {
+	scanner := pktline.NewScanner(bytes.NewReader(raw))
+	enc := pktline.NewEncoder(w)
+	first := true
+	for scanner.Scan() {
+		b := scanner.Bytes()
+		if len(b) == 0 { // flush packet
+			return enc.Flush()
+		}
+		if first {
+			// First line always passes — it carries server capabilities.
+			first = false
+			if err := enc.Encode(b); err != nil {
+				return err
+			}
+			continue
+		}
+		// Lines are "<sha> <refname>\n"; strip the newline to get the ref name.
+		line := strings.TrimSuffix(string(b), "\n")
+		parts := strings.SplitN(line, " ", 2)
+		if len(parts) == 2 {
+			ref := parts[1]
+			if strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/") {
+				if err := enc.Encode(b); err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return scanner.Err()
+}
+
+// parseReceivePackCommands reads the PKT-LINE ref-update command lines from the
+// receive-pack request body (up to and including the flush packet), extracts
+// the ref names, and returns an io.Reader that replays the full original body
+// (commands + flush + packfile) for the git subprocess.
+func parseReceivePackCommands(r io.Reader) (refs []string, full io.Reader, err error) {
+	// TeeReader mirrors everything consumed by the scanner into cmds, so we
+	// can replay it verbatim later.
+	var cmds bytes.Buffer
+	scanner := pktline.NewScanner(io.TeeReader(r, &cmds))
+	for scanner.Scan() {
+		b := scanner.Bytes()
+		if len(b) == 0 { // flush — end of command list
+			break
+		}
+		// Command format: "<old-sha> <new-sha> <refname>\0<caps>" (first line)
+		//              or "<old-sha> <new-sha> <refname>"          (subsequent)
+		line := strings.TrimSuffix(string(b), "\n")
+		if i := strings.IndexByte(line, 0); i >= 0 {
+			line = line[:i] // strip NUL + capability list
+		}
+		parts := strings.SplitN(line, " ", 3)
+		if len(parts) == 3 {
+			refs = append(refs, parts[2])
+		}
+	}
+	if err = scanner.Err(); err != nil {
+		return nil, nil, err
+	}
+	// cmds holds [commands + flush]; r holds the remaining packfile data.
+	return refs, io.MultiReader(&cmds, r), nil
+}
+
+// requestBody returns the request body, transparently decompressing it when
+// the client sent Content-Encoding: gzip (git does this by default).
+func requestBody(r *http.Request) (io.ReadCloser, error) {
+	if r.Header.Get("Content-Encoding") == "gzip" {
+		gr, err := gzip.NewReader(r.Body)
+		if err != nil {
+			return nil, err
+		}
+		return gr, nil
+	}
+	return r.Body, nil
+}
+
+// safeGitEnv returns a sanitised copy of the process environment for use with
+// git subprocesses.  Variables that could redirect git's operations to
+// unintended paths or trigger credential prompts are removed.
+func safeGitEnv() []string {
+	// These variables could redirect git internals to attacker-controlled
+	// paths or commands when the git-bug server process itself inherits a
+	// tainted environment.
+	blocked := map[string]bool{
+		"GIT_DIR":                          true,
+		"GIT_WORK_TREE":                    true,
+		"GIT_INDEX_FILE":                   true,
+		"GIT_OBJECT_DIRECTORY":             true,
+		"GIT_ALTERNATE_OBJECT_DIRECTORIES": true,
+		"GIT_EXEC_PATH":                    true,
+		"GIT_SSH":                          true,
+		"GIT_SSH_COMMAND":                  true,
+		"GIT_PROXY_COMMAND":                true,
+		"GIT_ASKPASS":                      true,
+		"SSH_ASKPASS":                      true,
+		"GIT_TRACE":                        true,
+		"GIT_TRACE_PACKET":                 true,
+		"GIT_TRACE_PERFORMANCE":            true,
+	}
+	parent := os.Environ()
+	safe := make([]string, 0, len(parent)+1)
+	for _, kv := range parent {
+		key := kv
+		if i := strings.IndexByte(kv, '='); i >= 0 {
+			key = kv[:i]
+		}
+		if !blocked[key] {
+			safe = append(safe, kv)
+		}
+	}
+	// Prevent git from blocking on a credential/passphrase prompt, which
+	// would hang the HTTP handler goroutine.
+	safe = append(safe, "GIT_TERMINAL_PROMPT=0")
+	return safe
+}

cache/repo_cache.go 🔗

@@ -41,6 +41,7 @@ type cacheMgmt interface {
 	RegisterObserver(repoName string, observer Observer)
 	UnregisterObserver(observer Observer)
 	Close() error
+	SyncLocalRef(id entity.Id) error
 }
 
 // RepoCache is a cache for a Repository. This cache has multiple functions:

cache/repo_cache_common.go 🔗

@@ -1,6 +1,7 @@
 package cache
 
 import (
+	"strings"
 	"sync"
 
 	"github.com/pkg/errors"
@@ -247,3 +248,22 @@ func (c *RepoCache) GetUserIdentityExcerpt() (*IdentityExcerpt, error) {
 func (c *RepoCache) IsUserIdentitySet() (bool, error) {
 	return identity.IsUserIdentitySet(c.repo)
 }
+
+// SyncLocalRefs updates the cache for each ref that was updated externally
+// (e.g. after a git push). Each ref is matched against the subcaches by
+// namespace and the corresponding entity is re-read from git.
+func (c *RepoCache) SyncLocalRefs(refs []string) error {
+	for _, ref := range refs {
+		id := entity.RefToId(ref)
+		for _, subcache := range c.subcaches {
+			ns := subcache.GetNamespace()
+			if strings.Contains(ref, "/"+ns+"/") {
+				if err := subcache.SyncLocalRef(id); err != nil {
+					return err
+				}
+				break
+			}
+		}
+	}
+	return nil
+}

cache/subcache.go 🔗

@@ -613,6 +613,9 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en
 				sc.cached[result.Id] = cached
 				sc.mu.Unlock()
 				sc.notifyObservers(EntityEventUpdated, result.Id)
+
+			default:
+				// nothing
 			}
 		}
 
@@ -677,6 +680,50 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) updateExcerptAndIndex(id entity.I
 	return sc.write()
 }
 
+// SyncLocalRef re-reads the entity with the given id from git and updates the
+// in-memory cache, search index, and on-disk excerpt cache. It is used to
+// refresh an entity after its git ref was updated externally (e.g. by a push).
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) SyncLocalRef(id entity.Id) error {
+	sc.mu.Lock()
+	_, existed := sc.excerpts[id]
+	delete(sc.cached, id)
+	sc.lru.Remove(id)
+	sc.mu.Unlock()
+
+	e, err := sc.actions.ReadWithResolver(sc.repo, sc.resolvers(), id)
+	if err != nil {
+		return err
+	}
+
+	cached := sc.makeCached(e, sc.entityUpdated)
+
+	sc.mu.Lock()
+	sc.excerpts[id] = sc.makeExcerpt(cached)
+	sc.cached[id] = cached
+	sc.lru.Add(id)
+	sc.mu.Unlock()
+
+	sc.evictIfNeeded()
+
+	index, err := sc.repo.GetIndex(sc.namespace)
+	if err != nil {
+		return err
+	}
+	if err = index.IndexOne(id.String(), sc.makeIndexData(cached)); err != nil {
+		return err
+	}
+	if err = sc.write(); err != nil {
+		return err
+	}
+
+	if existed {
+		sc.notifyObservers(EntityEventUpdated, id)
+	} else {
+		sc.notifyObservers(EntityEventCreated, id)
+	}
+	return nil
+}
+
 // evictIfNeeded will evict an entity from the cache if needed
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) evictIfNeeded() {
 	sc.mu.Lock()

commands/webui.go 🔗

@@ -197,6 +197,12 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	apiRepos.Path("/file/{hash}").Methods("GET").Handler(httpapi.NewGitFileHandler(mrc))
 	apiRepos.Path("/upload").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
 
+	// Git smart HTTP — clone, fetch, push.
+	gitSrv := httpapi.NewGitServeHandler(mrc, opts.readOnly)
+	apiRepos.Path("/info/refs").Methods("GET").HandlerFunc(gitSrv.ServeInfoRefs)
+	apiRepos.Path("/git-upload-pack").Methods("POST").HandlerFunc(gitSrv.ServeUploadPack)
+	apiRepos.Path("/git-receive-pack").Methods("POST").HandlerFunc(gitSrv.ServeReceivePack)
+
 	router.PathPrefix("/").Handler(webui2.NewHandler())
 
 	srv := &http.Server{

webui2/package-lock.json 🔗

@@ -24,6 +24,12 @@
         "react-dom": "^19.1.0",
         "react-markdown": "^9.0.1",
         "react-router-dom": "^6.28.0",
+        "rehype-autolink-headings": "^7.1.0",
+        "rehype-external-links": "^3.0.0",
+        "rehype-raw": "^7.0.0",
+        "rehype-sanitize": "^6.0.0",
+        "rehype-slug": "^6.0.0",
+        "remark-emoji": "^5.0.2",
         "remark-gfm": "^4.0.0",
         "tailwind-merge": "^2.5.5",
         "tailwindcss-animate": "^1.0.7"
@@ -3444,6 +3450,18 @@
         "win32"
       ]
     },
+    "node_modules/@sindresorhus/is": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+      "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
     "node_modules/@tailwindcss/typography": {
       "version": "0.5.19",
       "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
@@ -4147,6 +4165,15 @@
         "upper-case-first": "^2.0.2"
       }
     },
+    "node_modules/char-regex": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+      "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/character-entities": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -4638,6 +4665,34 @@
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
       "dev": true
     },
+    "node_modules/emojilib": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz",
+      "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==",
+      "license": "MIT"
+    },
+    "node_modules/emoticon": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz",
+      "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/error-ex": {
       "version": "1.3.4",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -4877,6 +4932,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/github-slugger": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+      "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+      "license": "ISC"
+    },
     "node_modules/glob-parent": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -5192,6 +5253,105 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/hast-util-from-parse5": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
+      "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@types/unist": "^3.0.0",
+        "devlop": "^1.0.0",
+        "hastscript": "^9.0.0",
+        "property-information": "^7.0.0",
+        "vfile": "^6.0.0",
+        "vfile-location": "^5.0.0",
+        "web-namespaces": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-heading-rank": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
+      "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-is-element": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+      "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-parse-selector": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
+      "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-raw": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
+      "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@types/unist": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-from-parse5": "^8.0.0",
+        "hast-util-to-parse5": "^8.0.0",
+        "html-void-elements": "^3.0.0",
+        "mdast-util-to-hast": "^13.0.0",
+        "parse5": "^7.0.0",
+        "unist-util-position": "^5.0.0",
+        "unist-util-visit": "^5.0.0",
+        "vfile": "^6.0.0",
+        "web-namespaces": "^2.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-sanitize": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
+      "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "unist-util-position": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/hast-util-to-jsx-runtime": {
       "version": "2.3.6",
       "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -5218,6 +5378,38 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/hast-util-to-parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
+      "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "devlop": "^1.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "web-namespaces": "^2.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-to-string": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
+      "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/hast-util-whitespace": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -5230,6 +5422,23 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/hastscript": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
+      "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "hast-util-parse-selector": "^4.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/header-case": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz",
@@ -5266,6 +5475,16 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/html-void-elements": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+      "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/http-proxy-agent": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -5448,6 +5667,18 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-absolute-url": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz",
+      "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==",
+      "license": "MIT",
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/is-alphabetical": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -6865,6 +7096,21 @@
         "node": ">=10.5.0"
       }
     },
+    "node_modules/node-emoji": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz",
+      "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
+      "license": "MIT",
+      "dependencies": {
+        "@sindresorhus/is": "^4.6.0",
+        "char-regex": "^1.0.2",
+        "emojilib": "^2.4.0",
+        "skin-tone": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/node-fetch": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -7071,6 +7317,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
     "node_modules/pascal-case": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
@@ -7566,6 +7824,104 @@
         }
       }
     },
+    "node_modules/rehype-autolink-headings": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
+      "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-heading-rank": "^3.0.0",
+        "hast-util-is-element": "^3.0.0",
+        "unified": "^11.0.0",
+        "unist-util-visit": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-external-links": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz",
+      "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-is-element": "^3.0.0",
+        "is-absolute-url": "^4.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "unist-util-visit": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-raw": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
+      "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "hast-util-raw": "^9.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-sanitize": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
+      "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "hast-util-sanitize": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-slug": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
+      "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "github-slugger": "^2.0.0",
+        "hast-util-heading-rank": "^3.0.0",
+        "hast-util-to-string": "^3.0.0",
+        "unist-util-visit": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-emoji": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-5.0.2.tgz",
+      "integrity": "sha512-IyIqGELcyK5AVdLFafoiNww+Eaw/F+rGrNSXoKucjo95uL267zrddgxGM83GN1wFIb68pyDuAsY3m5t2Cav1pQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.4",
+        "emoticon": "^4.0.1",
+        "mdast-util-find-and-replace": "^3.0.1",
+        "node-emoji": "^2.1.3",
+        "unified": "^11.0.4"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/remark-gfm": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -7873,6 +8229,18 @@
       "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
       "dev": true
     },
+    "node_modules/skin-tone": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz",
+      "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
+      "license": "MIT",
+      "dependencies": {
+        "unicode-emoji-modifier-base": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/slash": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -8358,6 +8726,15 @@
       "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
       "dev": true
     },
+    "node_modules/unicode-emoji-modifier-base": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz",
+      "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/unified": {
       "version": "11.0.5",
       "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -8595,6 +8972,20 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/vfile-location": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
+      "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/vfile-message": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -8720,6 +9111,16 @@
         "defaults": "^1.0.3"
       }
     },
+    "node_modules/web-namespaces": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+      "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/web-streams-polyfill": {
       "version": "3.3.3",
       "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -10967,6 +11368,11 @@
       "dev": true,
       "optional": true
     },
+    "@sindresorhus/is": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+      "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
+    },
     "@tailwindcss/typography": {
       "version": "0.5.19",
       "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
@@ -11469,6 +11875,11 @@
         "upper-case-first": "^2.0.2"
       }
     },
+    "char-regex": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+      "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="
+    },
     "character-entities": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -11815,6 +12226,21 @@
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
       "dev": true
     },
+    "emojilib": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz",
+      "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="
+    },
+    "emoticon": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.1.0.tgz",
+      "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ=="
+    },
+    "entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
+    },
     "error-ex": {
       "version": "1.3.4",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -11976,6 +12402,11 @@
       "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
       "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="
     },
+    "github-slugger": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+      "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="
+    },
     "glob-parent": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -12184,6 +12615,75 @@
         "function-bind": "^1.1.2"
       }
     },
+    "hast-util-from-parse5": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
+      "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "@types/unist": "^3.0.0",
+        "devlop": "^1.0.0",
+        "hastscript": "^9.0.0",
+        "property-information": "^7.0.0",
+        "vfile": "^6.0.0",
+        "vfile-location": "^5.0.0",
+        "web-namespaces": "^2.0.0"
+      }
+    },
+    "hast-util-heading-rank": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
+      "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
+      "requires": {
+        "@types/hast": "^3.0.0"
+      }
+    },
+    "hast-util-is-element": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+      "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+      "requires": {
+        "@types/hast": "^3.0.0"
+      }
+    },
+    "hast-util-parse-selector": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
+      "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
+      "requires": {
+        "@types/hast": "^3.0.0"
+      }
+    },
+    "hast-util-raw": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
+      "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "@types/unist": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-from-parse5": "^8.0.0",
+        "hast-util-to-parse5": "^8.0.0",
+        "html-void-elements": "^3.0.0",
+        "mdast-util-to-hast": "^13.0.0",
+        "parse5": "^7.0.0",
+        "unist-util-position": "^5.0.0",
+        "unist-util-visit": "^5.0.0",
+        "vfile": "^6.0.0",
+        "web-namespaces": "^2.0.0",
+        "zwitch": "^2.0.0"
+      }
+    },
+    "hast-util-sanitize": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz",
+      "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "unist-util-position": "^5.0.0"
+      }
+    },
     "hast-util-to-jsx-runtime": {
       "version": "2.3.6",
       "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -12206,6 +12706,28 @@
         "vfile-message": "^4.0.0"
       }
     },
+    "hast-util-to-parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
+      "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "devlop": "^1.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "web-namespaces": "^2.0.0",
+        "zwitch": "^2.0.0"
+      }
+    },
+    "hast-util-to-string": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
+      "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
+      "requires": {
+        "@types/hast": "^3.0.0"
+      }
+    },
     "hast-util-whitespace": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -12214,6 +12736,18 @@
         "@types/hast": "^3.0.0"
       }
     },
+    "hastscript": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
+      "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "hast-util-parse-selector": "^4.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0"
+      }
+    },
     "header-case": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz",
@@ -12242,6 +12776,11 @@
       "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
       "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="
     },
+    "html-void-elements": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+      "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="
+    },
     "http-proxy-agent": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -12372,6 +12911,11 @@
         "is-windows": "^1.0.1"
       }
     },
+    "is-absolute-url": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz",
+      "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A=="
+    },
     "is-alphabetical": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -13288,6 +13832,17 @@
       "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
       "dev": true
     },
+    "node-emoji": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz",
+      "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
+      "requires": {
+        "@sindresorhus/is": "^4.6.0",
+        "char-regex": "^1.0.2",
+        "emojilib": "^2.4.0",
+        "skin-tone": "^2.0.0"
+      }
+    },
     "node-fetch": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -13436,6 +13991,14 @@
         "lines-and-columns": "^1.1.6"
       }
     },
+    "parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "requires": {
+        "entities": "^6.0.0"
+      }
+    },
     "pascal-case": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
@@ -13714,6 +14277,75 @@
       "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==",
       "requires": {}
     },
+    "rehype-autolink-headings": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
+      "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-heading-rank": "^3.0.0",
+        "hast-util-is-element": "^3.0.0",
+        "unified": "^11.0.0",
+        "unist-util-visit": "^5.0.0"
+      }
+    },
+    "rehype-external-links": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz",
+      "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-is-element": "^3.0.0",
+        "is-absolute-url": "^4.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "unist-util-visit": "^5.0.0"
+      }
+    },
+    "rehype-raw": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
+      "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "hast-util-raw": "^9.0.0",
+        "vfile": "^6.0.0"
+      }
+    },
+    "rehype-sanitize": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz",
+      "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "hast-util-sanitize": "^5.0.0"
+      }
+    },
+    "rehype-slug": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
+      "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
+      "requires": {
+        "@types/hast": "^3.0.0",
+        "github-slugger": "^2.0.0",
+        "hast-util-heading-rank": "^3.0.0",
+        "hast-util-to-string": "^3.0.0",
+        "unist-util-visit": "^5.0.0"
+      }
+    },
+    "remark-emoji": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-5.0.2.tgz",
+      "integrity": "sha512-IyIqGELcyK5AVdLFafoiNww+Eaw/F+rGrNSXoKucjo95uL267zrddgxGM83GN1wFIb68pyDuAsY3m5t2Cav1pQ==",
+      "requires": {
+        "@types/mdast": "^4.0.4",
+        "emoticon": "^4.0.1",
+        "mdast-util-find-and-replace": "^3.0.1",
+        "node-emoji": "^2.1.3",
+        "unified": "^11.0.4"
+      }
+    },
     "remark-gfm": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -13931,6 +14563,14 @@
       "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
       "dev": true
     },
+    "skin-tone": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz",
+      "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
+      "requires": {
+        "unicode-emoji-modifier-base": "^1.0.0"
+      }
+    },
     "slash": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -14293,6 +14933,11 @@
       "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
       "dev": true
     },
+    "unicode-emoji-modifier-base": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz",
+      "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="
+    },
     "unified": {
       "version": "11.0.5",
       "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -14447,6 +15092,15 @@
         "vfile-message": "^4.0.0"
       }
     },
+    "vfile-location": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
+      "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
+      "requires": {
+        "@types/unist": "^3.0.0",
+        "vfile": "^6.0.0"
+      }
+    },
     "vfile-message": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -14495,6 +15149,11 @@
         "defaults": "^1.0.3"
       }
     },
+    "web-namespaces": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+      "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="
+    },
     "web-streams-polyfill": {
       "version": "3.3.3",
       "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

webui2/package.json 🔗

@@ -26,6 +26,12 @@
     "react-dom": "^19.1.0",
     "react-markdown": "^9.0.1",
     "react-router-dom": "^6.28.0",
+    "rehype-autolink-headings": "^7.1.0",
+    "rehype-external-links": "^3.0.0",
+    "rehype-raw": "^7.0.0",
+    "rehype-sanitize": "^6.0.0",
+    "rehype-slug": "^6.0.0",
+    "remark-emoji": "^5.0.2",
     "remark-gfm": "^4.0.0",
     "tailwind-merge": "^2.5.5",
     "tailwindcss-animate": "^1.0.7"

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

@@ -1,7 +1,32 @@
 import ReactMarkdown from 'react-markdown'
 import remarkGfm from 'remark-gfm'
+import remarkEmoji from 'remark-emoji'
+import rehypeRaw from 'rehype-raw'
+import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
+import rehypeSlug from 'rehype-slug'
+import rehypeAutolinkHeadings from 'rehype-autolink-headings'
+import rehypeExternalLinks from 'rehype-external-links'
 import { cn } from '@/lib/utils'
 
+// Sanitization schema: start from the safe default and allow a small set of
+// presentational/structural HTML tags commonly found in READMEs.
+// Script, style, iframe, object, embed and event-handler attributes are
+// blocked by the default schema and remain blocked.
+// rehype-autolink-headings injects <a> with aria-hidden and class, so we
+// allow those attributes on anchors.
+const sanitizeSchema = {
+  ...defaultSchema,
+  tagNames: [
+    ...(defaultSchema.tagNames ?? []),
+    'details', 'summary', 'picture', 'source',
+  ],
+  attributes: {
+    ...defaultSchema.attributes,
+    a: [...(defaultSchema.attributes?.a ?? []), 'aria-hidden', 'class'],
+    '*': [...(defaultSchema.attributes?.['*'] ?? []), 'id'],
+  },
+}
+
 interface MarkdownProps {
   content: string
   className?: string
@@ -12,11 +37,19 @@ interface MarkdownProps {
 export function Markdown({ content, className }: MarkdownProps) {
   return (
     <ReactMarkdown
-      remarkPlugins={[remarkGfm]}
+      remarkPlugins={[remarkGfm, remarkEmoji]}
+      rehypePlugins={[
+        rehypeRaw,
+        [rehypeSanitize, sanitizeSchema],
+        rehypeSlug,
+        [rehypeAutolinkHeadings, { behavior: 'append' }],
+        [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
+      ]}
       className={cn(
         'prose prose-sm dark:prose-invert max-w-none',
         'prose-pre:bg-muted prose-pre:text-foreground',
         'prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:before:content-none prose-code:after:content-none',
+        'prose-img:inline prose-img:my-0',
         className,
       )}
     >

webui2/src/index.css 🔗

@@ -32,25 +32,25 @@
   }
 
   .dark {
-    /* Dimmed dark — comfortable grey with blue-tinted accents. */
-    --background: 220 13% 13%;
-    --foreground: 220 12% 84%;
-    --card: 220 13% 16%;
-    --card-foreground: 220 12% 84%;
-    --popover: 220 13% 16%;
-    --popover-foreground: 220 12% 84%;
+    /* Softer dark — background lifted slightly, text dimmed to reduce glare. */
+    --background: 220 13% 15%;
+    --foreground: 220 10% 72%;
+    --card: 220 13% 18%;
+    --card-foreground: 220 10% 72%;
+    --popover: 220 13% 18%;
+    --popover-foreground: 220 10% 72%;
     --primary: 213 88% 62%;
     --primary-foreground: 220 20% 10%;
-    --secondary: 220 12% 22%;
-    --secondary-foreground: 220 12% 84%;
-    --muted: 220 12% 22%;
-    --muted-foreground: 220 8% 55%;
-    --accent: 220 20% 26%;
+    --secondary: 220 12% 24%;
+    --secondary-foreground: 220 10% 72%;
+    --muted: 220 12% 24%;
+    --muted-foreground: 220 8% 52%;
+    --accent: 220 20% 28%;
     --accent-foreground: 213 88% 72%;
     --destructive: 0 65% 50%;
     --destructive-foreground: 0 0% 98%;
-    --border: 220 12% 28%;
-    --input: 220 12% 28%;
+    --border: 220 12% 26%;
+    --input: 220 12% 26%;
     --ring: 213 88% 62%;
   }
 }

webui2/src/pages/CodePage.tsx 🔗

@@ -1,7 +1,7 @@
 import { useState, useEffect } from 'react'
 import { useSearchParams } from 'react-router-dom'
 import { gql, useQuery } from '@apollo/client'
-import { AlertCircle, GitCommit } from 'lucide-react'
+import { AlertCircle, Check, Copy, GitCommit } from 'lucide-react'
 import { CodeBreadcrumb } from '@/components/code/CodeBreadcrumb'
 import { RefSelector } from '@/components/code/RefSelector'
 import { FileTree } from '@/components/code/FileTree'
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
 import { getRefs, getTree, getBlob } from '@/lib/gitApi'
 import type { GitRef, GitTreeEntry, GitBlob } from '@/lib/gitApi'
 import { useRepo } from '@/lib/repo'
+import { Markdown } from '@/components/content/Markdown'
 
 const REPO_NAME_QUERY = gql`
   query RepoName($ref: String) {
@@ -35,6 +36,7 @@ export function CodePage() {
 
   const [entries, setEntries] = useState<GitTreeEntry[]>([])
   const [blob, setBlob] = useState<GitBlob | null>(null)
+  const [readme, setReadme] = useState<string | null>(null)
   const [contentLoading, setContentLoading] = useState(false)
 
   const currentRef = searchParams.get('ref') ?? ''
@@ -67,11 +69,26 @@ export function CodePage() {
     setContentLoading(true)
     setEntries([])
     setBlob(null)
+    setReadme(null)
 
     const load =
       viewMode === 'blob'
         ? getBlob(currentRef, currentPath).then((b) => setBlob(b))
-        : getTree(currentRef, currentPath).then((e) => setEntries(e))
+        : getTree(currentRef, currentPath).then((e) => {
+            setEntries(e)
+            const readmeEntry = e.find((entry) =>
+              entry.type === 'blob' &&
+              /^readme(\.md|\.txt|\.rst)?$/i.test(entry.name),
+            )
+            if (readmeEntry) {
+              const readmePath = currentPath
+                ? `${currentPath}/${readmeEntry.name}`
+                : readmeEntry.name
+              getBlob(currentRef, readmePath)
+                .then((b) => !b.isBinary && setReadme(b.content))
+                .catch(() => {/* best-effort */})
+            }
+          })
 
     load
       .catch((e: Error) => setError(e.message))
@@ -107,7 +124,17 @@ export function CodePage() {
   }
 
   const { data: repoData } = useQuery(REPO_NAME_QUERY, { variables: { ref: repo } })
-  const repoName = repoData?.repository?.name ?? repo ?? 'git-bug'
+  const repoName = repoData?.repository?.name ?? repo ?? 'default-repo'
+
+  const cloneUrl = `${window.location.origin}/api/repos/_/_`
+  const cloneCmd = `git clone ${cloneUrl} ${repoName}`
+  const [copied, setCopied] = useState(false)
+  function handleCopy() {
+    navigator.clipboard.writeText(cloneCmd).then(() => {
+      setCopied(true)
+      setTimeout(() => setCopied(false), 1500)
+    })
+  }
 
   if (error) {
     return (
@@ -160,17 +187,38 @@ export function CodePage() {
         </div>
       </div>
 
+      {/* Clone command */}
+      <div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-1.5">
+        <span className="text-xs text-muted-foreground shrink-0">clone</span>
+        <code className="flex-1 truncate text-xs">{cloneCmd}</code>
+        <Button variant="ghost" size="icon" className="size-6 shrink-0" onClick={handleCopy}>
+          {copied ? <Check className="size-3 text-green-600" /> : <Copy className="size-3" />}
+        </Button>
+      </div>
+
       {/* Content */}
       {viewMode === 'commits' ? (
         <CommitList ref_={currentRef} path={currentPath || undefined} />
       ) : viewMode === 'tree' || !blob ? (
-        <FileTree
-          entries={entries}
-          path={currentPath}
-          loading={contentLoading}
-          onNavigate={handleEntryClick}
-          onNavigateUp={handleNavigateUp}
-        />
+        <>
+          <FileTree
+            entries={entries}
+            path={currentPath}
+            loading={contentLoading}
+            onNavigate={handleEntryClick}
+            onNavigateUp={handleNavigateUp}
+          />
+          {readme && (
+            <div className="rounded-md border">
+              <div className="border-b px-4 py-2 text-xs font-medium text-muted-foreground">
+                README
+              </div>
+              <div className="px-6 py-4">
+                <Markdown content={readme} />
+              </div>
+            </div>
+          )}
+        </>
       ) : (
         <FileViewer blob={blob} ref={currentRef} loading={contentLoading} />
       )}