serve.go
package main
import (
"compress/gzip"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync/atomic"
"vitrine/display"
)
// snapshot is the server's immutable view of one loaded config:
// the opened repo set, the canonical base URL (empty when unconfigured;
// see config.BaseURL for what each state means), and the face.
// All of it travels together so a reload can never pair old repos with a new address
// or new identity.
type snapshot struct {
repos map[string]*repo
baseURL string
face face
}
// face is the config-derived visual identity: the displayed sitename, the theme it seeds,
// and the sketch assets and stylesheet drawn with that seed.
// It lives in the snapshot, not the server, because the sitename comes from the config
// and the config reloads live; rebuilding it is cheap and deterministic
// (an unchanged seed regenerates byte-identical assets).
type face struct {
siteName string
theme display.Theme
sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go
style []byte // the assembled /-/style.css body; see buildStyle
}
// buildFace resolves the display identity from a config and draws its assets.
// seedOverride (the -theme-seed flag) decouples the color from the name when set;
// it is a process-lifetime override, so it applies across reloads too.
func buildFace(cfg *config, seedOverride string) (face, error) {
name := cfg.Sitename
if name == "" && cfg.BaseURL != "" {
u, err := url.Parse(cfg.BaseURL)
if err != nil {
// Unreachable in practice: validateBaseURL already parsed it.
return face{}, fmt.Errorf("deriving sitename from baseurl: %w", err)
}
name = display.HostLabel(u.Host)
}
if name == "" {
hostname, err := os.Hostname()
if err != nil {
return face{}, fmt.Errorf("cannot determine a sitename: no sitename or baseurl configured, and reading the hostname failed: %w", err)
}
name = display.HostLabel(hostname)
}
seed := seedOverride
if seed == "" {
seed = name
}
th := display.NewTheme(seed)
return face{
siteName: name,
theme: th,
sketch: sketchAssets(seed),
style: buildStyle(th),
}, nil
}
// server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch.
// The config snapshot lives behind an atomic pointer so a reload can swap the whole thing
// 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 snapshot is never mutated.
type server struct {
snap atomic.Pointer[snapshot]
langs *langCache // language composition per (repo, sha); see lang.go
}
func newServer(repos map[string]*repo, baseURL string, fc face) *server {
s := &server{
langs: newLangCache(langCacheCap),
}
s.publish(repos, baseURL, fc)
return s
}
// publish swaps in a new config snapshot 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) publish(repos map[string]*repo, baseURL string, fc face) {
s.snap.Store(&snapshot{repos: repos, baseURL: baseURL, face: fc})
}
// 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, "/")
snap := s.snap.Load() // this request's snapshot; a concurrent reload cannot alter it
repos := snap.repos
// 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, snap)
return
case p == "-/style.css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(snap.face.style)
return
case strings.HasPrefix(p, "-/sketch/"):
serveSketch(w, snap, strings.TrimPrefix(p, "-/sketch/"))
return
}
rp, rest := matchRepo(repos, p)
if rp == nil {
s.notFoundPage(w, snap, "no exhibit at this address")
return
}
// The go toolchain's vanity-import probe outranks all other dispatch under a repo:
// go queries the full package path (so this must answer on subpaths, not just the home),
// and it must run before the trailing-slash redirect below, which would drop the query.
// Gated on a configured baseurl: the import prefix is a public claim that ends up in
// module caches and trackers, so socle only makes it on purpose -- without a baseurl
// the probe is inert and the URL means whatever it meant before.
if snap.baseURL != "" && r.URL.Query().Get("go-get") == "1" {
serveGoGetMeta(w, rp, snap.baseURL)
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, snap, 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, snap, rp, arg)
case verb == "raw":
s.serveRawBlob(w, r, rp, arg)
case verb == "log":
s.serveLog(w, r, snap, rp, arg)
case verb == "commit":
s.serveCommit(w, r, snap, rp, arg)
default:
s.redirectIntoTree(w, r, rp, rest)
}
}
// serveGoGetMeta answers the vanity-import probe: a minimal page whose go-import meta tag
// maps the import prefix onto this repo's clone URL, plus a go-source tag teaching
// pkg.go.dev to deep-link into socle's own tree pages.
// (The go-source file template carries no #L{line} fragment: file views have no line
// anchors, and an anchor that lands nowhere would be a small lie.)
// Written with Fprintf, no templating or escaping: both inputs are config-validated into
// HTML-inert charsets (baseURL to scheme://host, repo names to segments of [A-Za-z0-9._-]).
func serveGoGetMeta(w http.ResponseWriter, rp *repo, baseURL string) {
prefix := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://") + "/" + rp.name
repoURL := baseURL + "/" + rp.name
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html><head>
<meta name="go-import" content="%s git %s">
<meta name="go-source" content="%s %s/ %s/tree/HEAD{/dir} %s/tree/HEAD{/dir}/{file}">
</head><body><a href="/%s/">%s</a></body></html>
`, prefix, repoURL, prefix, repoURL, repoURL, repoURL, rp.name, prefix)
}
// pageError is httpError for the browse surfaces: a not-found renders as the themed
// empty-room page, since a stale link deserves the same room as a mistyped one.
// Everything else falls through to the plain mapping -- faults are not rooms.
func (s *server) pageError(w http.ResponseWriter, snap *snapshot, err error) {
if errors.Is(err, errNotFound) {
s.notFoundPage(w, snap, err.Error())
return
}
httpError(w, err)
}
// 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.
// The raw and git-protocol endpoints use this directly: their audience is tools,
// and tools want terse text, not a themed room.
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, snap *snapshot, rp *repo, arg string) {
ref, path := splitRefPath(arg)
sha, err := rp.resolveCommit(ref)
if err != nil {
s.pageError(w, snap, err)
return
}
typ, err := rp.entryType(sha, path)
if err != nil {
s.pageError(w, snap, err)
return
}
setCacheHeader(w, ref, sha)
switch typ {
case "tree":
s.serveTreeListing(w, snap, rp, ref, sha, path)
case "blob":
s.serveFileView(w, snap, rp, ref, sha, path)
default:
// A submodule pointer: there is nothing of it in this repo to show.
s.notFoundPage(w, snap, fmt.Sprintf("path %q is a submodule pointer, not browsable here", path))
}
}
// 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, snap *snapshot, rp *repo, arg string) {
ref := strings.TrimSuffix(arg, "/")
sha, err := rp.resolveCommit(ref)
if err != nil {
s.pageError(w, snap, err)
return
}
cs, err := rp.commits(sha, logLimit)
if err != nil {
s.pageError(w, snap, err)
return
}
setCacheHeader(w, ref, sha)
s.renderLog(w, snap, 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, snap *snapshot, rp *repo, arg string) {
ref := strings.TrimSuffix(arg, "/")
sha, err := rp.resolveCommit(ref)
if err != nil {
s.pageError(w, snap, err)
return
}
c, err := rp.commit(sha)
if err != nil {
s.pageError(w, snap, err)
return
}
diff, err := rp.diff(c)
if err != nil {
s.pageError(w, snap, err)
return
}
setCacheHeader(w, ref, sha)
s.renderCommit(w, snap, 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 · 14.8 KiB · 245d322a2e