package core import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "log/slog" "net/http" "strconv" "strings" "sync" "time" "gsg/internal/gitx" ) type Server struct { Site *Site Routes []Route Log *slog.Logger Timeout time.Duration // per-request git budget MaxGit int64 // per-command output cap, bytes // TemplateDir, when set, re-parses templates on every request. Theme // iteration without a restart; leave empty in production. TemplateDir string TemplateFS fs.FS mu sync.RWMutex templates TemplateSet tmplTag string // changes when templates change; part of every ETag cache Store } func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) { s := &Server{ Site: site, Routes: routes, TemplateFS: tfs, Log: slog.Default(), Timeout: 10 * time.Second, MaxGit: 8 << 20, cache: NewLRU(cacheSize), } if err := s.loadTemplates(); err != nil { return nil, err } return s, nil } func (s *Server) loadTemplates() error { fsys := s.TemplateFS set, err := LoadTemplates(fsys) if err != nil { return err } tag, err := hashFS(fsys) if err != nil { return err } s.mu.Lock() s.templates, s.tmplTag = set, tag s.mu.Unlock() return nil } // hashFS digests every template so that editing the theme invalidates every // ETag at once. Without this, a restyled page keeps serving 304s. func hashFS(fsys fs.FS) (string, error) { h := sha256.New() err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } b, err := fs.ReadFile(fsys, p) if err != nil { return err } fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b)) return nil }) return hex.EncodeToString(h.Sum(nil))[:12], err } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() for _, rt := range s.Routes { rt := rt mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) { s.serve(rt, w, r) }) } return mux } func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) { if s.TemplateDir != "" { if err := s.loadTemplates(); err != nil { s.fail(w, r, rt, err) return } } ctx, cancel := contextWithTimeout(r, s.Timeout) defer cancel() c, err := s.buildCtx(ctx, rt, r) if err != nil { s.fail(w, r, rt, err) return } c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref) // ETag is derived from the resolved commit SHA, not the response body, // so a conditional request costs one ref lookup instead of a render. s.mu.RLock() tag := s.tmplTag ts := s.templates s.mu.RUnlock() // contentIdentity carries the commit, keyring, and every Var (e.g. // compare's base ref) -- the same fragment module results are keyed // on, so this can't drift out of sync with what CacheKey covers. etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity()) + `"` w.Header().Set("ETag", etag) if c.Immutable { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) { w.WriteHeader(http.StatusNotModified) return } body, err := rt.Render.Render(c, ts) if err != nil { s.fail(w, r, rt, err) return } w.Header().Set("Content-Type", rt.Render.ContentType()) if hs, ok := rt.Render.(HeaderSetter); ok { hs.Headers(c, w.Header()) } w.Write(body) } func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) { if rt.Scope == ScopeSite { c := NewCtx(s.Site, nil, RepoInfo{}, "") c.Store = s.cache return c, nil } slug := r.PathValue("repo") h, ok := s.Site.Lookup(slug) if !ok { return nil, Errorf(http.StatusNotFound, "no such repository %q", slug) } repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit) // {rest...} carries ref and path fused together; {rev} is a bare ref; // {range} is "base..head" or "base...head". ref, path := "", "" var base string dots := "2" switch { case r.PathValue("range") != "": rng := r.PathValue("range") var head string if b, hd, ok := strings.Cut(rng, "..."); ok { base, head, dots = b, hd, "3" } else if b, hd, ok := strings.Cut(rng, ".."); ok { base, head = b, hd } else { return nil, Errorf(http.StatusBadRequest, "compare needs base..head") } ref = head case r.PathValue("rest") != "": ref, path = repo.SplitRefPath(r.PathValue("rest")) case r.PathValue("rev") != "": ref = r.PathValue("rev") default: ref = repo.HeadRef() } c := NewCtx(s.Site, repo, h.Info, ref) c.Path = path c.Store = s.cache if !repo.Empty() { sha, err := repo.Resolve(ref) if err != nil { return nil, Errorf(http.StatusNotFound, "%v", err) } c.Commit = sha } if base != "" { baseSHA, err := repo.Resolve(base) if err != nil { return nil, Errorf(http.StatusNotFound, "%v", err) } headSHA := c.Commit // Two-dot compares are undirected as far as the caller is concerned: // whichever ref is the ancestor should read as "base", regardless of // which side of ".." it was typed on. Three-dot is left alone -- // its merge-base semantics are direction-sensitive by design. if dots == "2" && baseSHA != headSHA { if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc { base, ref = ref, base baseSHA, headSHA = headSHA, baseSHA } } c.Ref, c.Commit = ref, headSHA c.Vars["base"], c.Vars["head"] = base, ref c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA c.Vars["dots"] = dots } if rt.Name == "log" || rt.Name == "graph" { pageSize := LogPageSize if rt.Name == "graph" { pageSize = GraphPageSize } if v := r.URL.Query().Get("ofs"); v != "" { if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 { ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key c.Vars["ofs"] = strconv.Itoa(ofs) } } } // One source of truth for the immutable policy: the cache header in // serve() reads the same field, so page chrome that must not be frozen // into a year-long response is suppressed by exactly the condition that // makes the response year-long. c.Immutable = rt.Immutable && c.Commit != "" return c, nil } func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) { code := statusOf(err) if errors.Is(err, gitx.ErrTruncated) { code = http.StatusRequestEntityTooLarge } if code >= 500 { s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err) } else { s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err) } // serve() sets ETag/Cache-Control before Render runs, assuming success. // An error response must never inherit those, especially the // year-long immutable Cache-Control on commit routes. w.Header().Del("ETag") w.Header().Set("Cache-Control", "no-store") body, cType := renderErrorPage(code, s.Site.Name) w.Header().Set("Content-Type", cType) w.WriteHeader(code) w.Write(body) } func shortHash(parts ...string) string { h := sha256.New() for _, p := range parts { h.Write([]byte(p)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil))[:16] }