socle

serve.go

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
	sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go
	style  []byte            // the assembled /-/style.css body; see buildStyle
}

func newServer(repos map[string]*repo, th display.Theme) *server {
	return &server{
		repos:  repos,
		theme:  th,
		sketch: sketchAssets(th.Name),
		style:  buildStyle(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 {
	case p == "":
		s.serveIndex(w, r)
		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(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)
	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 · 9.5 KiB · 2bf3fecf7c