vitrine

repoview: tiny read-only web viewer for fusion source trees

commit ef04c20893885891e12a227dde0a6273a9268b65 · browse this tree
Ximianth, 2026-07-23 20:53:13 UTC

Serves configured roots (currently just moonshot-1) under their name
as a URL prefix.  Default view is rendered: goldmark for markdown,
directory listings for dirs, and an iframe passthrough for other files.
?fmt=raw serves file bytes; markdown gets text/plain so browsers
display it inline.

Path containment relies on os.Root rather than hand-rolled checks.
Deployed via the included systemd unit, binary installed to
/usr/local/bin (kept out of the repo).

Chose Go over 11ty: node isn't installed here, and a live server
tracks the working tree without a rebuild step.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..cf0e751
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module repoview
+
+go 1.26.5
+
+require github.com/yuin/goldmark v1.8.4 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a63e17b
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
+github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..a1b5c57
--- /dev/null
+++ b/main.go
@@ -0,0 +1,31 @@
+// Command repoview is a tiny read-only web viewer for local source trees.
+//
+// Each configured root is served under its name as a URL prefix,
+// e.g. "/moonshot-1/crates/iri/filetests/verify-errors.md".
+// The default view is rendered (markdown becomes HTML, directories become listings);
+// appending "?fmt=raw" serves the file bytes directly.
+package main
+
+import (
+	"flag"
+	"log"
+	"net/http"
+)
+
+// roots maps a URL name to a filesystem directory.
+// Hardcoded for now; lift into a config file when we want more than a couple.
+var roots = map[string]string{
+	"moonshot-1": "/home/exedev/fusion/moonshot-1",
+}
+
+func main() {
+	addr := flag.String("addr", ":8000", "listen address")
+	flag.Parse()
+
+	srv, err := newServer(roots)
+	if err != nil {
+		log.Fatal(err)
+	}
+	log.Printf("repoview listening on %s", *addr)
+	log.Fatal(http.ListenAndServe(*addr, srv))
+}
diff --git a/render.go b/render.go
new file mode 100644
index 0000000..3fabbf6
--- /dev/null
+++ b/render.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+	"net/http"
+	"os"
+	"strings"
+
+	"github.com/yuin/goldmark"
+	gmext "github.com/yuin/goldmark/extension"
+)
+
+// maxRenderSize caps how much we'll read into memory for rendered views.
+// Bigger files still work fine via ?fmt=raw, which streams.
+const maxRenderSize = 4 << 20
+
+var markdown = goldmark.New(goldmark.WithExtensions(gmext.GFM))
+
+type dirEntry struct {
+	Name string
+	Href string
+}
+
+type crumb struct {
+	Name string
+	Href string
+}
+
+type pageData struct {
+	Title   string
+	Crumbs  []crumb
+	RawHref string        // if set, a "raw" link is shown
+	Listing []dirEntry    // directory listing mode
+	Content template.HTML // rendered-markdown mode
+	Iframe  string        // iframe-passthrough mode: src URL
+}
+
+// crumbs builds a breadcrumb trail: home, root name, then each path segment.
+func crumbs(name, rel string) []crumb {
+	cs := []crumb{{Name: "repoview", Href: "/"}}
+	if name == "" {
+		return cs
+	}
+	href := "/" + name + "/"
+	cs = append(cs, crumb{Name: name, Href: href})
+	if rel == "." {
+		return cs
+	}
+	for _, seg := range strings.Split(rel, "/") {
+		href += seg + "/"
+		cs = append(cs, crumb{Name: seg, Href: href})
+	}
+	// The last segment may be a file; its correct href has no trailing slash,
+	// but browsers resolve it identically because we link the exact page anyway.
+	cs[len(cs)-1].Href = ""
+	return cs
+}
+
+// serveRendered shows a file as a page: markdown rendered to HTML,
+// anything else passed through in an iframe pointed at the raw view.
+func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, name, rel string, size int64) {
+	pd := pageData{
+		Title:   name + "/" + rel,
+		Crumbs:  crumbs(name, rel),
+		RawHref: "?fmt=raw",
+	}
+	if strings.HasSuffix(rel, ".md") && size <= maxRenderSize {
+		src, err := root.ReadFile(rel)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		var buf bytes.Buffer
+		if err := markdown.Convert(src, &buf); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		pd.Content = template.HTML(buf.String())
+	} else {
+		pd.Iframe = "?fmt=raw"
+	}
+	renderPage(w, pd)
+}
+
+var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<style>
+body { font-family: sans-serif; max-width: 56rem; margin: 1rem auto; padding: 0 1rem; }
+nav { border-bottom: 1px solid #ccc; padding-bottom: .5rem; margin-bottom: 1rem; }
+nav .raw { float: right; }
+ul.listing { list-style: none; padding-left: 0; }
+ul.listing li { padding: .1rem 0; }
+iframe.passthrough { width: 100%; height: 75vh; border: 1px solid #ccc; }
+article pre { background: #f4f4f4; padding: .5rem; overflow-x: auto; }
+article code { background: #f4f4f4; }
+article table { border-collapse: collapse; }
+article th, article td { border: 1px solid #ccc; padding: .2rem .5rem; }
+</style>
+</head>
+<body>
+<nav>
+{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{end}}
+{{if .RawHref}}<a class="raw" href="{{.RawHref}}">raw</a>{{end}}
+</nav>
+{{if .Listing}}<ul class="listing">{{range .Listing}}<li><a href="{{.Href}}">{{.Name}}</a></li>{{end}}</ul>{{end}}
+{{if .Content}}<article>{{.Content}}</article>{{end}}
+{{if .Iframe}}<iframe class="passthrough" src="{{.Iframe}}"></iframe>{{end}}
+</body>
+</html>
+`))
+
+func renderPage(w http.ResponseWriter, pd pageData) {
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := pageTmpl.Execute(w, pd); err != nil {
+		fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
+	}
+}
diff --git a/repoview.service b/repoview.service
new file mode 100644
index 0000000..136a3eb
--- /dev/null
+++ b/repoview.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=repoview: tiny read-only web viewer for fusion source trees
+After=network.target
+
+[Service]
+ExecStart=/usr/local/bin/repoview -addr :8000
+User=exedev
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/serve.go b/serve.go
new file mode 100644
index 0000000..8b88259
--- /dev/null
+++ b/serve.go
@@ -0,0 +1,131 @@
+package main
+
+import (
+	"fmt"
+	"io/fs"
+	"net/http"
+	"os"
+	"path"
+	"sort"
+	"strings"
+)
+
+// server holds one opened os.Root per configured tree.
+// os.Root guarantees all path resolution stays inside the tree
+// (it rejects ".." escapes and symlinks pointing outside).
+type server struct {
+	trees map[string]*os.Root
+}
+
+func newServer(roots map[string]string) (*server, error) {
+	s := &server{trees: make(map[string]*os.Root)}
+	for name, dir := range roots {
+		root, err := os.OpenRoot(dir)
+		if err != nil {
+			return nil, fmt.Errorf("opening root %q: %w", name, err)
+		}
+		s.trees[name] = root
+	}
+	return s, nil
+}
+
+func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Path == "/" {
+		s.serveIndex(w, r)
+		return
+	}
+
+	name, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
+	root, ok := s.trees[name]
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	rel := path.Clean("/" + rest)[1:] // normalize; "" becomes "."
+	if rel == "" {
+		rel = "."
+	}
+
+	info, err := root.Stat(rel)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+
+	switch {
+	case info.IsDir():
+		if !strings.HasSuffix(r.URL.Path, "/") {
+			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
+			return
+		}
+		s.serveDir(w, r, root, name, rel)
+	case r.URL.Query().Get("fmt") == "raw":
+		serveRaw(w, r, root, rel)
+	default:
+		serveRendered(w, r, root, name, rel, info.Size())
+	}
+}
+
+// serveRaw sends the file bytes.
+// Markdown gets text/plain so browsers display it instead of downloading;
+// everything else gets stdlib content-type detection via ServeContent.
+func serveRaw(w http.ResponseWriter, r *http.Request, root *os.Root, rel string) {
+	f, err := root.Open(rel)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	defer f.Close()
+	info, err := f.Stat()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if strings.HasSuffix(rel, ".md") {
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	}
+	http.ServeContent(w, r, info.Name(), info.ModTime(), f)
+}
+
+// serveIndex lists the configured roots.
+func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) {
+	names := make([]string, 0, len(s.trees))
+	for name := range s.trees {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	entries := make([]dirEntry, len(names))
+	for i, name := range names {
+		entries[i] = dirEntry{Name: name + "/", Href: "/" + name + "/"}
+	}
+	renderPage(w, pageData{Title: "repoview", Crumbs: crumbs("", ""), Listing: entries})
+}
+
+// serveDir renders a directory listing.
+// The .git directory is omitted: it's not useful to browse and clutters the view.
+func (s *server) serveDir(w http.ResponseWriter, r *http.Request, root *os.Root, name, rel string) {
+	ents, err := fs.ReadDir(root.FS(), rel)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var listing []dirEntry
+	if rel != "." {
+		listing = append(listing, dirEntry{Name: "../", Href: "../"})
+	}
+	for _, e := range ents {
+		if e.Name() == ".git" {
+			continue
+		}
+		n := e.Name()
+		if e.IsDir() {
+			n += "/"
+		}
+		listing = append(listing, dirEntry{Name: n, Href: n})
+	}
+	renderPage(w, pageData{
+		Title:   name + "/" + strings.TrimSuffix(rel, "."),
+		Crumbs:  crumbs(name, rel),
+		Listing: listing,
+	})
+}
diff --git a/serve_test.go b/serve_test.go
new file mode 100644
index 0000000..0f34748
--- /dev/null
+++ b/serve_test.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// newTestServer builds a server around a throwaway tree.
+func newTestServer(t *testing.T) *server {
+	t.Helper()
+	dir := t.TempDir()
+	files := map[string]string{
+		"README.md":     "# Hello\n\nsome *markdown* & <angle> stuff\n",
+		"sub/notes.txt": "plain text here\n",
+		"sub/deeper.md": "nested doc\n",
+		".git/config":   "should be hidden\n",
+	}
+	for name, content := range files {
+		p := filepath.Join(dir, filepath.FromSlash(name))
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	srv, err := newServer(map[string]string{"demo": dir})
+	if err != nil {
+		t.Fatal(err)
+	}
+	return srv
+}
+
+// get performs a request and returns the response.
+func get(t *testing.T, srv *server, url string) *httptest.ResponseRecorder {
+	t.Helper()
+	rec := httptest.NewRecorder()
+	srv.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
+	return rec
+}
+
+func TestRawMarkdownIsPlaintextPassthrough(t *testing.T) {
+	rec := get(t, newTestServer(t), "/demo/README.md?fmt=raw")
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d", rec.Code)
+	}
+	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
+		t.Errorf("content-type = %q, want text/plain", ct)
+	}
+	if got := rec.Body.String(); got != "# Hello\n\nsome *markdown* & <angle> stuff\n" {
+		t.Errorf("body = %q; want exact file bytes", got)
+	}
+}
+
+func TestRenderedMarkdown(t *testing.T) {
+	rec := get(t, newTestServer(t), "/demo/README.md")
+	body := rec.Body.String()
+	if !strings.Contains(body, "<h1") || !strings.Contains(body, "<em>markdown</em>") {
+		t.Errorf("markdown not rendered; body:\n%s", body)
+	}
+	if !strings.Contains(body, `href="?fmt=raw"`) {
+		t.Errorf("missing raw link; body:\n%s", body)
+	}
+}
+
+func TestNonMarkdownGetsIframe(t *testing.T) {
+	rec := get(t, newTestServer(t), "/demo/sub/notes.txt")
+	if !strings.Contains(rec.Body.String(), `<iframe class="passthrough" src="?fmt=raw"`) {
+		t.Errorf("missing iframe; body:\n%s", rec.Body.String())
+	}
+}
+
+func TestDirListingHidesGit(t *testing.T) {
+	rec := get(t, newTestServer(t), "/demo/")
+	body := rec.Body.String()
+	if !strings.Contains(body, `href="sub/"`) || !strings.Contains(body, `href="README.md"`) {
+		t.Errorf("listing incomplete; body:\n%s", body)
+	}
+	if strings.Contains(body, ".git") {
+		t.Errorf(".git leaked into listing; body:\n%s", body)
+	}
+}
+
+func TestDirRedirectAddsSlash(t *testing.T) {
+	rec := get(t, newTestServer(t), "/demo/sub")
+	if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/demo/sub/" {
+		t.Errorf("got %d -> %q", rec.Code, rec.Header().Get("Location"))
+	}
+}
+
+func TestTraversalRejected(t *testing.T) {
+	for _, url := range []string{
+		"/demo/../../../etc/passwd",
+		"/demo/..%2f..%2fetc/passwd",
+		"/nosuchroot/x",
+	} {
+		rec := get(t, newTestServer(t), url)
+		if rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently {
+			t.Errorf("%s: status = %d, want rejection", url, rec.Code)
+		}
+		if strings.Contains(rec.Body.String(), "root:") {
+			t.Errorf("%s: leaked /etc/passwd", url)
+		}
+	}
+}
+
+func TestRootIndexListsTrees(t *testing.T) {
+	rec := get(t, newTestServer(t), "/")
+	if !strings.Contains(rec.Body.String(), `href="/demo/"`) {
+		t.Errorf("index missing tree link; body:\n%s", rec.Body.String())
+	}
+}