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