serve.go
package main
import (
"compress/gzip"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync/atomic"
"vitrine/display"
)
// server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch.
// The repo set lives behind an atomic pointer so a config reload can swap the whole set
// at once while requests read it lock-free: each request loads the pointer exactly once
// (at the top of ServeHTTP) and works with that immutable snapshot for its whole duration.
// A published map is never mutated.
type server struct {
repos atomic.Pointer[map[string]*repo]
theme display.Theme // fixed for the process lifetime; see display/theme.go
sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go
style []byte // the assembled /-/style.css body; see buildStyle
langs *langCache // language composition per (repo, sha); see lang.go
}
func newServer(repos map[string]*repo, th display.Theme) *server {
s := &server{
theme: th,
sketch: sketchAssets(th.Name),
style: buildStyle(th),
langs: newLangCache(langCacheCap),
}
s.setRepos(repos)
return s
}
// setRepos publishes a new repo set atomically; in-flight requests keep the one they loaded.
// The old set needs no teardown: a repo holds only strings (git runs as a subprocess per
// operation), so the garbage collector reclaims it once the last such request drops it.
// The language-stats cache deliberately survives the swap: it is keyed (repo-name, commit-sha)
// and a sha content-addresses the commit's tree, so an entry can be orphaned by a reload
// but never wrong, and the cache's cap already bounds orphans.
func (s *server) setRepos(repos map[string]*repo) {
s.repos.Store(&repos)
}
// 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, "/")
repos := *s.repos.Load() // this request's snapshot; a concurrent reload cannot alter it
// Fetch RPC is the only POST in the whole URL space; everything else is GET (or HEAD).
isRPC := false
if _, rest := matchRepo(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 {
case p == "":
s.serveIndex(w, r, repos)
return
case p == "-/style.css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(s.style)
return
case strings.HasPrefix(p, "-/sketch/"):
s.serveSketch(w, strings.TrimPrefix(p, "-/sketch/"))
return
}
rp, rest := matchRepo(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)
case verb == "commit":
s.serveCommit(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)
}
// serveCommit handles /<name>/commit/<sha>: the commit's metadata, message, and diff.
// Any ref is accepted where <sha> is written, but links always use full shas,
// so the pages that matter get the immutable cache header.
func (s *server) serveCommit(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
}
c, err := rp.commit(sha)
if err != nil {
httpError(w, err)
return
}
diff, err := rp.diff(c)
if err != nil {
httpError(w, err)
return
}
setCacheHeader(w, ref, sha)
s.renderCommit(w, rp, c, diff)
}
// 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
}
serve.go · 10.6 KiB · 4eed84cb53