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()) + } +}