Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/core/server.go

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 6.3 KB · 253 lines raw

1 package core
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io/fs"
9 "log/slog"
10 "net/http"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
16 "gsg/internal/gitx"
17 )
18
19 type Server struct {
20 Site *Site
21 Routes []Route
22 Log *slog.Logger
23 Timeout time.Duration // per-request git budget
24 MaxGit int64 // per-command output cap, bytes
25
26 // TemplateDir, when set, re-parses templates on every request. Theme
27 // iteration without a restart; leave empty in production.
28 TemplateDir string
29 TemplateFS fs.FS
30
31 mu sync.RWMutex
32 templates TemplateSet
33 tmplTag string // changes when templates change; part of every ETag
34 cache Store
35 }
36
37 func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) {
38 s := &Server{
39 Site: site, Routes: routes, TemplateFS: tfs,
40 Log: slog.Default(),
41 Timeout: 10 * time.Second,
42 MaxGit: 8 << 20,
43 cache: NewLRU(cacheSize),
44 }
45 if err := s.loadTemplates(); err != nil {
46 return nil, err
47 }
48 return s, nil
49 }
50
51 func (s *Server) loadTemplates() error {
52 fsys := s.TemplateFS
53 set, err := LoadTemplates(fsys)
54 if err != nil {
55 return err
56 }
57 tag, err := hashFS(fsys)
58 if err != nil {
59 return err
60 }
61 s.mu.Lock()
62 s.templates, s.tmplTag = set, tag
63 s.mu.Unlock()
64 return nil
65 }
66
67 // hashFS digests every template so that editing the theme invalidates every
68 // ETag at once. Without this, a restyled page keeps serving 304s.
69 func hashFS(fsys fs.FS) (string, error) {
70 h := sha256.New()
71 err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
72 if err != nil || d.IsDir() {
73 return err
74 }
75 b, err := fs.ReadFile(fsys, p)
76 if err != nil {
77 return err
78 }
79 fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b))
80 return nil
81 })
82 return hex.EncodeToString(h.Sum(nil))[:12], err
83 }
84
85 func (s *Server) Handler() http.Handler {
86 mux := http.NewServeMux()
87 for _, rt := range s.Routes {
88 rt := rt
89 mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) {
90 s.serve(rt, w, r)
91 })
92 }
93 return mux
94 }
95
96 func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
97 if s.TemplateDir != "" {
98 if err := s.loadTemplates(); err != nil {
99 s.fail(w, r, rt, err)
100 return
101 }
102 }
103
104 ctx, cancel := contextWithTimeout(r, s.Timeout)
105 defer cancel()
106
107 c, err := s.buildCtx(ctx, rt, r)
108 if err != nil {
109 s.fail(w, r, rt, err)
110 return
111 }
112 c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref)
113
114 // ETag is derived from the resolved commit SHA, not the response body,
115 // so a conditional request costs one ref lookup instead of a render.
116 s.mu.RLock()
117 tag := s.tmplTag
118 ts := s.templates
119 s.mu.RUnlock()
120
121 // contentIdentity carries the commit, keyring, and every Var (e.g.
122 // compare's base ref) -- the same fragment module results are keyed
123 // on, so this can't drift out of sync with what CacheKey covers.
124 etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity()) + `"`
125 w.Header().Set("ETag", etag)
126 if rt.Immutable && c.Commit != "" {
127 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
128 } else {
129 w.Header().Set("Cache-Control", "no-cache")
130 }
131 if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
132 w.WriteHeader(http.StatusNotModified)
133 return
134 }
135
136 body, err := rt.Render.Render(c, ts)
137 if err != nil {
138 s.fail(w, r, rt, err)
139 return
140 }
141 w.Header().Set("Content-Type", rt.Render.ContentType())
142 if hs, ok := rt.Render.(HeaderSetter); ok {
143 hs.Headers(c, w.Header())
144 }
145 w.Write(body)
146 }
147
148 func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
149 if rt.Scope == ScopeSite {
150 c := NewCtx(s.Site, nil, RepoInfo{}, "")
151 c.Store = s.cache
152 return c, nil
153 }
154
155 slug := r.PathValue("repo")
156 h, ok := s.Site.Lookup(slug)
157 if !ok {
158 return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
159 }
160 repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
161
162 // {rest...} carries ref and path fused together; {rev} is a bare ref;
163 // {range} is "base..head" or "base...head".
164 ref, path := "", ""
165 var base string
166 dots := "2"
167
168 switch {
169 case r.PathValue("range") != "":
170 rng := r.PathValue("range")
171 var head string
172 if b, hd, ok := strings.Cut(rng, "..."); ok {
173 base, head, dots = b, hd, "3"
174 } else if b, hd, ok := strings.Cut(rng, ".."); ok {
175 base, head = b, hd
176 } else {
177 return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
178 }
179 ref = head
180 case r.PathValue("rest") != "":
181 ref, path = repo.SplitRefPath(r.PathValue("rest"))
182 case r.PathValue("rev") != "":
183 ref = r.PathValue("rev")
184 default:
185 ref = repo.HeadRef()
186 }
187
188 c := NewCtx(s.Site, repo, h.Info, ref)
189 c.Path = path
190 c.Store = s.cache
191
192 if !repo.Empty() {
193 sha, err := repo.Resolve(ref)
194 if err != nil {
195 return nil, Errorf(http.StatusNotFound, "%v", err)
196 }
197 c.Commit = sha
198 }
199 if base != "" {
200 baseSHA, err := repo.Resolve(base)
201 if err != nil {
202 return nil, Errorf(http.StatusNotFound, "%v", err)
203 }
204 c.Vars["base"], c.Vars["head"] = base, ref
205 c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, c.Commit
206 c.Vars["dots"] = dots
207 }
208 if rt.Name == "log" || rt.Name == "graph" {
209 pageSize := LogPageSize
210 if rt.Name == "graph" {
211 pageSize = GraphPageSize
212 }
213 if v := r.URL.Query().Get("ofs"); v != "" {
214 if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
215 ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key
216 c.Vars["ofs"] = strconv.Itoa(ofs)
217 }
218 }
219 }
220 return c, nil
221 }
222
223 func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
224 code := statusOf(err)
225 if errors.Is(err, gitx.ErrTruncated) {
226 code = http.StatusRequestEntityTooLarge
227 }
228 if code >= 500 {
229 s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err)
230 } else {
231 s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
232 }
233
234 // serve() sets ETag/Cache-Control before Render runs, assuming success.
235 // An error response must never inherit those, especially the
236 // year-long immutable Cache-Control on commit routes.
237 w.Header().Del("ETag")
238 w.Header().Set("Cache-Control", "no-store")
239
240 body, cType := renderErrorPage(code, s.Site.Name)
241 w.Header().Set("Content-Type", cType)
242 w.WriteHeader(code)
243 w.Write(body)
244 }
245
246 func shortHash(parts ...string) string {
247 h := sha256.New()
248 for _, p := range parts {
249 h.Write([]byte(p))
250 h.Write([]byte{0})
251 }
252 return hex.EncodeToString(h.Sum(nil))[:16]
253 }