socle

serve: dispatch, browse pages, smart-HTTP fetch, listener wiring

commit e4143c23e17aecd93c078bd09d056e74a8934061 · parent 717173c127 · browse this tree
Ximianth, 2026-10-26 20:02:33 UTC

The server dispatches by longest-prefix repo match (matchRepo), then the first
remaining path segment picks the verb: tree, raw, log, info/refs, git-upload-pack.
Page content renders through vitrine's display package; socle adds only a small
CSS block for its tables and file views.

Notable shapes:
- Typed plumbing errors map to statuses in one place (httpError): 400/404/500.
- Full-sha-addressed tree/raw/log responses get Cache-Control: immutable.
- info/refs refuses anything but service=git-upload-pack with 403; the
  "# service=" pkt-line preamble is written only for protocol v0/v1, matching
  git http-backend's behavior when the client asks for v2 via Git-Protocol.
- POST bodies with Content-Encoding: gzip are unwrapped (git sends them).
- /<name>/<path> with no verb redirects into tree/<defaultbranch>/<path>
  (raw for image extensions), so relative links and images in rendered
  READMEs resolve usefully.

Smoke-tested against real local repos: all pages render, git clone works
end to end (protocol v2).  Automated tests land next.
diff --git a/go.mod b/go.mod
index 50e735a..0f45e20 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,18 @@ require (
 	vitrine v0.0.0
 )
 
+require (
+	github.com/disintegration/imaging v1.6.2 // indirect
+	github.com/flopp/go-findfont v0.1.0 // indirect
+	github.com/fogleman/gg v1.3.0 // indirect
+	github.com/goccy/go-graphviz v0.2.10 // indirect
+	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+	github.com/tetratelabs/wazero v1.10.1 // indirect
+	github.com/yuin/goldmark v1.8.4 // indirect
+	golang.org/x/image v0.21.0 // indirect
+	golang.org/x/text v0.19.0 // indirect
+)
+
 // vitrine is a sibling project, not yet published anywhere; its display
 // subpackage provides the markdown rendering and page chrome socle uses.
 replace vitrine => ../common/vitrine
diff --git a/go.sum b/go.sum
index b59f50c..4dbf992 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,31 @@
+github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
+github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=
+github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw=
+github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
+github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
+github.com/goccy/go-graphviz v0.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g=
+github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
+github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
 github.com/warpfork/go-fsx v0.3.0/go.mod h1:oTACCMj+Zle+vgVa5SAhGAh7WksYpLgGUCKEAVc+xPg=
 github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
 github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
+github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
+github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
+golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index cdfa485..bac54ec 100644
--- a/main.go
+++ b/main.go
@@ -6,14 +6,28 @@ package main
 import (
 	"flag"
 	"log"
+	"net/http"
+	"os"
+
+	"vitrine/display"
 )
 
 func main() {
 	configPath := flag.String("config", "", "path to the socle config file (required; there is no default location)")
+	listen := flag.String("listen", ":8080", "listen address")
+	themeSeed := flag.String("theme-seed", "", "theme name override (default: first dot-segment of the hostname)")
 	flag.Parse()
 	if *configPath == "" {
 		log.Fatal("socle: the -config flag is required")
 	}
+	seed := *themeSeed
+	if seed == "" {
+		hostname, err := os.Hostname()
+		if err != nil {
+			log.Fatalf("socle: cannot determine hostname for theming (use -theme-seed to bypass): %v", err)
+		}
+		seed = display.HostLabel(hostname)
+	}
 	cfg, err := loadConfig(*configPath)
 	if err != nil {
 		log.Fatal("socle: ", err)
@@ -22,7 +36,6 @@ func main() {
 	for _, w := range warnings {
 		log.Print("socle: ", w)
 	}
-	log.Printf("socle: opened %d repos", len(repos))
-	// The HTTP serve layer is the next piece to land here;
-	// until it does, a successful start just reports what it would serve, and exits.
+	log.Printf("socle: serving %d repos on %s", len(repos), *listen)
+	log.Fatal(http.ListenAndServe(*listen, newServer(repos, display.NewTheme(seed))))
 }
diff --git a/pages.go b/pages.go
new file mode 100644
index 0000000..d5d8d68
--- /dev/null
+++ b/pages.go
@@ -0,0 +1,471 @@
+package main
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"mime"
+	"net/http"
+	"path"
+	"strings"
+
+	"vitrine/display"
+)
+
+// This file builds the browse pages: HTML content fragments (html/template throughout,
+// so everything that came out of a repo is escaped) placed into display.PageData.Content,
+// with chrome, crumbs, and theme coming from the display package.
+
+const siteName = "socle"
+
+// logLimit caps the commit log page; history older than this is reachable by
+// following parent shas (and a real pager can come later if it's ever missed).
+const logLimit = 100
+
+// socleCSS is appended after display.StyleCSS in /-/style.css: just enough for
+// socle's own tables and file views, staying in vitrine's visual family.
+const socleCSS = `
+/* socle additions: listing and log tables, file views. */
+article table.tree td, article table.tree th,
+article table.log td, article table.log th { border: none; padding: .15rem .6rem; }
+article table.tree tbody tr:hover, article table.log tbody tr:hover { background: var(--well); }
+article th { text-align: left; }
+article td.size { text-align: right; color: var(--ink-soft); }
+article td.date { color: var(--ink-soft); white-space: nowrap; }
+article td.hash a { font-family: monospace; }
+article p.clone code { user-select: all; }
+article img.file { max-width: 100%; }
+`
+
+// renderPage fills in the site-wide theme and writes the full page.
+func (s *server) renderPage(w http.ResponseWriter, pd display.PageData) {
+	pd.ThemeName = s.theme.Name
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := display.Page(w, pd); err != nil {
+		// Headers are gone already; leave a trace in the body and the log.
+		log.Print("socle: ", err)
+		fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
+	}
+}
+
+// render executes one content-fragment template; a failure is an internal error (500).
+func (s *server) render(w http.ResponseWriter, tmpl *template.Template, data any, pd display.PageData) {
+	var buf bytes.Buffer
+	if err := tmpl.Execute(&buf, data); err != nil {
+		httpError(w, err)
+		return
+	}
+	pd.Content = template.HTML(buf.String())
+	s.renderPage(w, pd)
+}
+
+// treeHref builds the browse URL for a path at a ref; dirs get a trailing slash by the caller.
+// Escaping is left to html/template's URL normalization in the templates.
+func treeHref(name, ref, pth string) string {
+	h := "/" + name + "/tree/" + ref
+	if pth != "" {
+		h += "/" + pth
+	}
+	return h
+}
+
+func rawHref(name, ref, pth string) string {
+	return "/" + name + "/raw/" + ref + "/" + pth
+}
+
+// isImagePath reports whether a filename looks like an image a browser renders natively:
+// those inline via the raw URL instead of getting a text or download treatment.
+func isImagePath(pth string) bool {
+	switch strings.ToLower(path.Ext(pth)) {
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico":
+		return true
+	}
+	return false
+}
+
+// rawContentType picks the Content-Type for a raw blob response, by extension only
+// (blob content is not consulted).  Markdown is forced to text/plain so browsers display
+// rather than download it.  Empty means "let net/http sniff the first bytes".
+func rawContentType(pth string) string {
+	ext := strings.ToLower(path.Ext(pth))
+	if ext == ".md" {
+		return "text/plain; charset=utf-8"
+	}
+	return mime.TypeByExtension(ext)
+}
+
+// crumbLink is one segment of the breadcrumb nav; an empty Href renders as plain text.
+type crumbLink = display.Crumb
+
+// repoCrumbs starts a breadcrumb trail: site, then the repo (its full name as one crumb,
+// slashes and all: intermediate name segments are not pages).
+func repoCrumbs(name string, more ...crumbLink) []display.Crumb {
+	cs := []display.Crumb{{Name: siteName, Href: "/"}, {Name: name, Href: "/" + name + "/"}}
+	cs = append(cs, more...)
+	cs[len(cs)-1].Href = "" // the last crumb is the page being viewed
+	return cs
+}
+
+// ---- index ----
+
+var indexTmpl = template.Must(template.New("index").Parse(`<h1>repositories</h1>
+<table class="log">
+<tbody>
+{{range .}}<tr><td><a href="/{{.Name}}/">{{.Name}}</a></td><td>{{.Description}}</td><td class="date">{{.Head}}</td></tr>
+{{end}}</tbody>
+</table>
+`))
+
+type indexRow struct {
+	Name        string
+	Description string
+	Head        string // subject of the newest commit on the default branch; empty if none
+}
+
+func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) {
+	var rows []indexRow
+	for _, name := range sortedNames(s.repos) {
+		rp := s.repos[name]
+		row := indexRow{Name: name, Description: rp.description}
+		if cs, err := rp.commits(rp.defaultBranch(), 1); err == nil && len(cs) > 0 {
+			row.Head = cs[0].subject
+		}
+		rows = append(rows, row)
+	}
+	s.render(w, indexTmpl, rows, display.PageData{
+		Title:  siteName,
+		Crumbs: []display.Crumb{{Name: siteName}},
+	})
+}
+
+// ---- repo home ----
+
+var homeTmpl = template.Must(template.New("home").Parse(`{{if .Description}}<p>{{.Description}}</p>
+{{end}}<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
+{{if .Empty}}<p>This repository has no commits yet.</p>
+{{else}}<p><a href="{{.FilesHref}}">files</a> · <a href="{{.LogHref}}">log</a>
+· branches: {{range $i, $b := .Branches}}{{if $i}}, {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}{{if .Tags}}
+· tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$t.Href}}">{{$t.Name}}</a>{{end}}{{end}}</p>
+{{end}}{{.Readme}}`))
+
+type refLink struct {
+	Name string
+	Href string
+}
+
+type homeData struct {
+	Description string
+	CloneURL    string
+	Empty       bool // no commits yet: nothing to browse
+	FilesHref   string
+	LogHref     string
+	Branches    []refLink
+	Tags        []refLink
+	Readme      template.HTML
+}
+
+func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) {
+	scheme := "http"
+	if r.TLS != nil {
+		scheme = "https"
+	}
+	db := rp.defaultBranch()
+	d := homeData{
+		Description: rp.description,
+		CloneURL:    scheme + "://" + r.Host + "/" + rp.name,
+		FilesHref:   treeHref(rp.name, db, "") + "/",
+		LogHref:     "/" + rp.name + "/log/" + db,
+	}
+	branches, tags, err := rp.refs()
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	for _, b := range branches {
+		d.Branches = append(d.Branches, refLink{b.name, treeHref(rp.name, b.name, "") + "/"})
+	}
+	for _, t := range tags {
+		d.Tags = append(d.Tags, refLink{t.name, treeHref(rp.name, t.name, "") + "/"})
+	}
+	sha, err := rp.resolveCommit("HEAD")
+	switch {
+	case err == nil:
+		d.Readme, err = s.renderReadme(rp, db, sha)
+		if err != nil {
+			httpError(w, err)
+			return
+		}
+	case errors.Is(err, errNotFound):
+		d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare
+	default:
+		httpError(w, err)
+		return
+	}
+	s.render(w, homeTmpl, d, display.PageData{
+		Title:  rp.name,
+		Crumbs: repoCrumbs(rp.name),
+	})
+}
+
+// renderReadme finds and renders the README blob in the root tree at a commit, if any:
+// the first root entry named readme.md (any case) renders as markdown;
+// failing that, readme or readme.txt (any case) shows preformatted.  No README is fine.
+// ref is used for link targets so browsing stays on the branch the viewer arrived at.
+func (s *server) renderReadme(rp *repo, ref, sha string) (template.HTML, error) {
+	entries, err := rp.lsTree(sha, "")
+	if err != nil {
+		return "", err
+	}
+	name, markdown := "", false
+	for _, e := range entries {
+		if e.typ != "blob" || e.size > display.MaxRenderSize {
+			continue
+		}
+		switch strings.ToLower(e.name) {
+		case "readme.md":
+			name, markdown = e.name, true
+		case "readme", "readme.txt":
+			if name == "" {
+				name = e.name
+			}
+		}
+	}
+	if name == "" {
+		return "", nil
+	}
+	var buf bytes.Buffer
+	if err := rp.catBlob(sha, name, &buf); err != nil {
+		return "", err
+	}
+	if markdown {
+		return display.Markdown(buf.Bytes())
+	}
+	var out bytes.Buffer
+	if err := preTmpl.Execute(&out, buf.String()); err != nil {
+		return "", err
+	}
+	return template.HTML(out.String()), nil
+}
+
+// ---- tree listing ----
+
+var treeTmpl = template.Must(template.New("tree").Parse(`<h1>{{.Title}}</h1>
+<table class="tree">
+<tbody>
+{{range .Rows}}<tr><td>{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td class="size">{{.Size}}</td></tr>
+{{end}}</tbody>
+</table>
+`))
+
+type treeRow struct {
+	Name string
+	Href string // empty renders unlinked (submodules)
+	Size string // blob size in bytes; empty for non-blobs
+}
+
+type treeData struct {
+	Title string
+	Rows  []treeRow
+}
+
+func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
+	entries, err := rp.lsTree(sha, pth)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	d := treeData{Title: pth}
+	if pth == "" {
+		d.Title = rp.name
+	}
+	if pth != "" {
+		d.Rows = append(d.Rows, treeRow{Name: "..", Href: treeHref(rp.name, ref, path.Dir(strings.TrimSuffix(pth, "/"))) + "/"})
+	}
+	for _, e := range entries {
+		full := e.name
+		if pth != "" {
+			full = pth + "/" + e.name
+		}
+		row := treeRow{Name: e.name}
+		switch e.typ {
+		case "tree":
+			row.Name += "/"
+			row.Href = treeHref(rp.name, ref, full) + "/"
+		case "blob":
+			row.Href = treeHref(rp.name, ref, full)
+			row.Size = fmt.Sprint(e.size)
+		default: // a submodule pointer: nothing here to link to
+			row.Name += " @ " + shortSha(e.hash)
+		}
+		d.Rows = append(d.Rows, row)
+	}
+	s.render(w, treeTmpl, d, display.PageData{
+		Title:  pageTitle(rp.name, ref, pth),
+		Crumbs: treeCrumbs(rp.name, ref, pth),
+	})
+}
+
+// treeCrumbs: site / repo / ref / path segments, each linking to its listing.
+func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb {
+	cs := []crumbLink{{Name: ref, Href: treeHref(name, ref, "") + "/"}}
+	if pth != "" {
+		segs := strings.Split(pth, "/")
+		for i, seg := range segs {
+			cs = append(cs, crumbLink{Name: seg, Href: treeHref(name, ref, strings.Join(segs[:i+1], "/")) + "/"})
+		}
+	}
+	return repoCrumbs(name, append(cs, more...)...)
+}
+
+func pageTitle(name, ref, pth string) string {
+	t := name
+	if pth != "" {
+		t += "/" + pth
+	}
+	return t + " @ " + shortSha(ref)
+}
+
+// shortSha abbreviates full hex shas for display; anything else (branch names) passes through.
+func shortSha(ref string) string {
+	if len(ref) == 40 && !strings.ContainsFunc(ref, func(c rune) bool { return !isHex(c) }) {
+		return ref[:10]
+	}
+	return ref
+}
+
+func isHex(c rune) bool {
+	return c >= '0' && c <= '9' || c >= 'a' && c <= 'f'
+}
+
+// ---- file views ----
+
+var (
+	preTmpl   = template.Must(template.New("pre").Parse(`<pre class="file">{{.}}</pre>`))
+	imageTmpl = template.Must(template.New("image").Parse(`<h1>{{.Name}}</h1>
+<p><img class="file" src="{{.RawHref}}" alt="{{.Name}}"></p>
+`))
+	fileTmpl = template.Must(template.New("file").Parse(`<h1>{{.Name}}</h1>
+{{.Body}}`))
+	rawLinkTmpl = template.Must(template.New("rawlink").Parse(`<h1>{{.Name}}</h1>
+<p>{{.Size}} bytes of {{.Kind}}; <a href="{{.RawHref}}">view raw</a>.</p>
+`))
+)
+
+// serveFileView shows a blob as a page: markdown rendered, text preformatted, images inlined,
+// and anything binary or oversized as a link to the raw view.
+func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
+	name := path.Base(pth)
+	raw := rawHref(rp.name, ref, pth)
+	pd := display.PageData{
+		Title:   pageTitle(rp.name, ref, pth),
+		Crumbs:  treeCrumbs(rp.name, ref, path.Dir(pth), crumbLink{Name: name}),
+		RawHref: raw,
+	}
+	if isImagePath(pth) {
+		s.render(w, imageTmpl, struct{ Name, RawHref string }{name, raw}, pd)
+		return
+	}
+	size, err := rp.blobSize(sha, pth)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	rawLink := func(kind string) {
+		s.render(w, rawLinkTmpl, struct {
+			Name, Kind, RawHref string
+			Size                int64
+		}{name, kind, raw, size}, pd)
+	}
+	if size > display.MaxRenderSize {
+		rawLink("file, too large to display inline")
+		return
+	}
+	var buf bytes.Buffer
+	if err := rp.catBlob(sha, pth, &buf); err != nil {
+		httpError(w, err)
+		return
+	}
+	if looksBinary(buf.Bytes()) {
+		rawLink("binary data")
+		return
+	}
+	if strings.ToLower(path.Ext(pth)) == ".md" {
+		body, err := display.Markdown(buf.Bytes())
+		if err != nil {
+			httpError(w, err)
+			return
+		}
+		s.render(w, fileTmpl, struct {
+			Name string
+			Body template.HTML
+		}{name, body}, pd)
+		return
+	}
+	var pre bytes.Buffer
+	if err := preTmpl.Execute(&pre, buf.String()); err != nil {
+		httpError(w, err)
+		return
+	}
+	s.render(w, fileTmpl, struct {
+		Name string
+		Body template.HTML
+	}{name, template.HTML(pre.String())}, pd)
+}
+
+// looksBinary applies the classic cheap heuristic: a NUL byte in the leading bytes means
+// "not text".  Content that dodges this (a NUL late in a big file) still renders;
+// it is just ugly, not harmful, since everything goes through template escaping anyway.
+func looksBinary(b []byte) bool {
+	if len(b) > 8000 {
+		b = b[:8000]
+	}
+	return bytes.IndexByte(b, 0) >= 0
+}
+
+// ---- log ----
+
+var logTmpl = template.Must(template.New("log").Parse(`<h1>log: {{.Title}}</h1>
+<table class="log">
+<thead><tr><th>commit</th><th>date</th><th>author</th><th>subject</th></tr></thead>
+<tbody>
+{{range .Rows}}<tr><td class="hash"><a href="{{.Href}}">{{.Sha}}</a></td><td class="date">{{.Date}}</td><td>{{.Author}}</td><td>{{.Subject}}</td></tr>
+{{end}}</tbody>
+</table>
+{{if .Capped}}<p>Showing the {{.Limit}} most recent commits.</p>
+{{end}}`))
+
+type logRow struct {
+	Sha     string
+	Href    string
+	Date    string
+	Author  string
+	Subject string
+}
+
+type logData struct {
+	Title  string
+	Rows   []logRow
+	Capped bool
+	Limit  int
+}
+
+// renderLog writes the log page.  Commit shas link to the tree at that commit:
+// commit-diff pages are a later stretch goal, and browsing the snapshot is useful today.
+func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []commitInfo) {
+	d := logData{Title: shortSha(ref), Capped: len(cs) == logLimit, Limit: logLimit}
+	for _, c := range cs {
+		d.Rows = append(d.Rows, logRow{
+			Sha:     shortSha(c.hash),
+			Href:    treeHref(rp.name, c.hash, "") + "/",
+			Date:    c.time.Format("2006-01-02"),
+			Author:  c.author,
+			Subject: c.subject,
+		})
+	}
+	s.render(w, logTmpl, d, display.PageData{
+		Title:  rp.name + " log @ " + shortSha(ref),
+		Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
+	})
+}
diff --git a/serve.go b/serve.go
new file mode 100644
index 0000000..7ff16ec
--- /dev/null
+++ b/serve.go
@@ -0,0 +1,269 @@
+package main
+
+import (
+	"compress/gzip"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"vitrine/display"
+)
+
+// server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch.
+// The repo set is fixed at startup (no live reload yet); requests only ever read it.
+type server struct {
+	repos map[string]*repo
+	theme display.Theme // fixed for the process lifetime; see display/theme.go
+}
+
+func newServer(repos map[string]*repo, th display.Theme) *server {
+	return &server{repos: repos, theme: th}
+}
+
+// ServeHTTP dispatches: the two server-wide paths first, then longest-prefix repo matching
+// (matchRepo), then the first path segment after the repo name selects the verb.
+func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	p := strings.TrimPrefix(r.URL.Path, "/")
+
+	// Fetch RPC is the only POST in the whole URL space; everything else is GET (or HEAD).
+	isRPC := false
+	if _, rest := matchRepo(s.repos, p); rest == "git-upload-pack" {
+		isRPC = true
+	}
+	switch {
+	case isRPC && r.Method != http.MethodPost:
+		http.Error(w, "git-upload-pack requires POST", http.StatusMethodNotAllowed)
+		return
+	case !isRPC && r.Method != http.MethodGet && r.Method != http.MethodHead:
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	switch p {
+	case "":
+		s.serveIndex(w, r)
+		return
+	case "-/style.css":
+		// The theme's :root block comes last: at equal specificity the later declaration wins,
+		// which is what lets the theme's --accent override the stylesheet's default.
+		w.Header().Set("Content-Type", "text/css; charset=utf-8")
+		w.Write(display.StyleCSS)
+		w.Write([]byte(socleCSS))
+		w.Write(s.theme.CSS)
+		return
+	}
+
+	rp, rest := matchRepo(s.repos, p)
+	if rp == nil {
+		http.NotFound(w, r)
+		return
+	}
+	if rest == "" {
+		// Canonicalize the repo home to a trailing slash, so relative URLs in the
+		// rendered README resolve under the repo's own prefix.
+		if !strings.HasSuffix(r.URL.Path, "/") {
+			http.Redirect(w, r, r.URL.EscapedPath()+"/", http.StatusMovedPermanently)
+			return
+		}
+		s.serveHome(w, r, rp)
+		return
+	}
+
+	verb, arg, _ := strings.Cut(rest, "/")
+	switch {
+	case verb == "info" && arg == "refs":
+		s.serveInfoRefs(w, r, rp)
+	case verb == "git-upload-pack" && arg == "":
+		s.serveUploadPack(w, r, rp)
+	case verb == "tree":
+		s.serveTree(w, r, rp, arg)
+	case verb == "raw":
+		s.serveRawBlob(w, r, rp, arg)
+	case verb == "log":
+		s.serveLog(w, r, rp, arg)
+	default:
+		s.redirectIntoTree(w, r, rp, rest)
+	}
+}
+
+// httpError maps a plumbing-layer error onto an HTTP response:
+// errInvalid is the client's fault (400), errNotFound speaks for itself (404),
+// and anything else is internal (500) -- logged in full, reported generically.
+func httpError(w http.ResponseWriter, err error) {
+	switch {
+	case errors.Is(err, errInvalid):
+		http.Error(w, err.Error(), http.StatusBadRequest)
+	case errors.Is(err, errNotFound):
+		http.Error(w, err.Error(), http.StatusNotFound)
+	default:
+		log.Print("socle: ", err)
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+	}
+}
+
+// setCacheHeader marks a response immutable when the URL addressed a commit by its full sha:
+// such a URL can never mean anything else, so clients and scrapers may keep it forever.
+// ref is the ref as it appeared in the URL; sha is what resolveCommit returned for it.
+func setCacheHeader(w http.ResponseWriter, ref, sha string) {
+	if ref == sha {
+		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+	}
+}
+
+// splitRefPath splits a "<ref>/<path>" verb argument.  The ref is a single URL segment
+// (the known MVP limitation: branch names containing "/" aren't URL-addressable);
+// a trailing slash on the path is tolerated and means the same thing without it.
+func splitRefPath(arg string) (ref, path string) {
+	ref, path, _ = strings.Cut(arg, "/")
+	return ref, strings.TrimSuffix(path, "/")
+}
+
+// serveTree handles /<name>/tree/<ref>[/<path>]: a listing for trees, a file view for blobs.
+func (s *server) serveTree(w http.ResponseWriter, r *http.Request, rp *repo, arg string) {
+	ref, path := splitRefPath(arg)
+	sha, err := rp.resolveCommit(ref)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	typ, err := rp.entryType(sha, path)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	setCacheHeader(w, ref, sha)
+	switch typ {
+	case "tree":
+		s.serveTreeListing(w, rp, ref, sha, path)
+	case "blob":
+		s.serveFileView(w, rp, ref, sha, path)
+	default:
+		// A submodule pointer: there is nothing of it in this repo to show.
+		http.Error(w, fmt.Sprintf("path %q is a submodule pointer, not browsable here", path), http.StatusNotFound)
+	}
+}
+
+// serveRawBlob handles /<name>/raw/<ref>/<path>: the blob's bytes, verbatim.
+func (s *server) serveRawBlob(w http.ResponseWriter, r *http.Request, rp *repo, arg string) {
+	ref, path := splitRefPath(arg)
+	sha, err := rp.resolveCommit(ref)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	typ, err := rp.entryType(sha, path)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	if typ != "blob" {
+		http.Error(w, fmt.Sprintf("path %q is not a file", path), http.StatusNotFound)
+		return
+	}
+	size, err := rp.blobSize(sha, path)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	setCacheHeader(w, ref, sha)
+	if ct := rawContentType(path); ct != "" {
+		w.Header().Set("Content-Type", ct)
+	}
+	// With no Content-Type set, net/http sniffs one from the first bytes written.
+	w.Header().Set("Content-Length", fmt.Sprint(size))
+	if r.Method == http.MethodHead {
+		return
+	}
+	if err := rp.catBlob(sha, path, w); err != nil && r.Context().Err() == nil {
+		// Too late to change the status; the short write against Content-Length
+		// already tells the client the response is broken.
+		log.Print("socle: ", err)
+	}
+}
+
+// serveLog handles /<name>/log/<ref>.
+func (s *server) serveLog(w http.ResponseWriter, r *http.Request, rp *repo, arg string) {
+	ref := strings.TrimSuffix(arg, "/")
+	sha, err := rp.resolveCommit(ref)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	cs, err := rp.commits(sha, logLimit)
+	if err != nil {
+		httpError(w, err)
+		return
+	}
+	setCacheHeader(w, ref, sha)
+	s.renderLog(w, rp, ref, cs)
+}
+
+// redirectIntoTree sends /<name>/<path> (any path that isn't a verb) into the tree at the
+// default branch, so relative links in a rendered README land somewhere sensible.
+// Image-ish extensions redirect to the raw view instead, so a README's relative
+// <img> references actually display.
+func (s *server) redirectIntoTree(w http.ResponseWriter, r *http.Request, rp *repo, rest string) {
+	rest = strings.TrimSuffix(rest, "/")
+	verb := "tree"
+	if isImagePath(rest) {
+		verb = "raw"
+	}
+	target := url.URL{Path: "/" + rp.name + "/" + verb + "/" + rp.defaultBranch() + "/" + rest}
+	http.Redirect(w, r, target.EscapedPath(), http.StatusFound)
+}
+
+// serveInfoRefs handles GET /<name>/info/refs: the smart-HTTP fetch advertisement.
+// Any service other than git-upload-pack is refused outright: socle never serves
+// receive-pack, and the explicit 403 says so to pushing clients.
+func (s *server) serveInfoRefs(w http.ResponseWriter, r *http.Request, rp *repo) {
+	if svc := r.URL.Query().Get("service"); svc != "git-upload-pack" {
+		http.Error(w, "this server serves fetch only (service=git-upload-pack)", http.StatusForbidden)
+		return
+	}
+	protocol := r.Header.Get("Git-Protocol")
+	w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
+	w.Header().Set("Cache-Control", "no-cache")
+	// The "# service=" pkt-line preamble belongs to protocol v0/v1 responses only;
+	// under v2 the client expects the capability advertisement to start immediately
+	// (git's own http-backend skips it the same way).
+	if !wantsProtocolV2(protocol) {
+		const preamble = "# service=git-upload-pack\n"
+		fmt.Fprintf(w, "%04x%s0000", len(preamble)+4, preamble)
+	}
+	if err := rp.uploadPackAdvertise(r.Context(), w, protocol); err != nil && r.Context().Err() == nil {
+		log.Print("socle: ", err)
+	}
+}
+
+// serveUploadPack handles POST /<name>/git-upload-pack: one stateless fetch RPC exchange.
+func (s *server) serveUploadPack(w http.ResponseWriter, r *http.Request, rp *repo) {
+	body := r.Body
+	if r.Header.Get("Content-Encoding") == "gzip" {
+		zr, err := gzip.NewReader(body)
+		if err != nil {
+			http.Error(w, "malformed gzip request body", http.StatusBadRequest)
+			return
+		}
+		defer zr.Close()
+		body = zr
+	}
+	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
+	w.Header().Set("Cache-Control", "no-cache")
+	if err := rp.uploadPackRPC(r.Context(), body, w, r.Header.Get("Git-Protocol")); err != nil && r.Context().Err() == nil {
+		log.Print("socle: ", err)
+	}
+}
+
+// wantsProtocolV2 reports whether a Git-Protocol header value asks for protocol v2.
+// The header is a colon-separated list of key=value entries.
+func wantsProtocolV2(protocol string) bool {
+	for _, entry := range strings.Split(protocol, ":") {
+		if strings.TrimSpace(entry) == "version=2" {
+			return true
+		}
+	}
+	return false
+}