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

internal/core/server.go

3a50036d96eb14462e96598fd169d1c79264ac4b · 5.6 KB · 236 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 "strings"
12 "sync"
13 "time"
14
15 "gsg/internal/gitx"
16 )
17
18 type Server struct {
19 Site *Site
20 Routes []Route
21 Log *slog.Logger
22 Timeout time.Duration // per-request git budget
23 MaxGit int64 // per-command output cap, bytes
24
25 // TemplateDir, when set, re-parses templates on every request. Theme
26 // iteration without a restart; leave empty in production.
27 TemplateDir string
28 TemplateFS fs.FS
29
30 mu sync.RWMutex
31 templates TemplateSet
32 tmplTag string // changes when templates change; part of every ETag
33 cache Store
34 repos map[string]Handle
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 repos: map[string]Handle{},
45 }
46 for _, h := range site.Repos {
47 if !h.Info.Hidden {
48 s.repos[h.Info.Slug] = h
49 }
50 }
51 if err := s.loadTemplates(); err != nil {
52 return nil, err
53 }
54 return s, nil
55 }
56
57 func (s *Server) loadTemplates() error {
58 fsys := s.TemplateFS
59 set, err := LoadTemplates(fsys)
60 if err != nil {
61 return err
62 }
63 tag, err := hashFS(fsys)
64 if err != nil {
65 return err
66 }
67 s.mu.Lock()
68 s.templates, s.tmplTag = set, tag
69 s.mu.Unlock()
70 return nil
71 }
72
73 // hashFS digests every template so that editing the theme invalidates every
74 // ETag at once. Without this, a restyled page keeps serving 304s.
75 func hashFS(fsys fs.FS) (string, error) {
76 h := sha256.New()
77 err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
78 if err != nil || d.IsDir() {
79 return err
80 }
81 b, err := fs.ReadFile(fsys, p)
82 if err != nil {
83 return err
84 }
85 fmt.Fprintf(h, "%s:%x\n", p, sha256.Sum256(b))
86 return nil
87 })
88 return hex.EncodeToString(h.Sum(nil))[:12], err
89 }
90
91 func (s *Server) Handler() http.Handler {
92 mux := http.NewServeMux()
93 for _, rt := range s.Routes {
94 rt := rt
95 mux.HandleFunc(rt.Pattern, func(w http.ResponseWriter, r *http.Request) {
96 s.serve(rt, w, r)
97 })
98 }
99 return mux
100 }
101
102 func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
103 if s.TemplateDir != "" {
104 if err := s.loadTemplates(); err != nil {
105 s.fail(w, r, rt, err)
106 return
107 }
108 }
109
110 ctx, cancel := contextWithTimeout(r, s.Timeout)
111 defer cancel()
112
113 c, err := s.buildCtx(ctx, rt, r)
114 if err != nil {
115 s.fail(w, r, rt, err)
116 return
117 }
118
119 // ETag is derived from the resolved commit SHA, not the response body,
120 // so a conditional request costs one ref lookup instead of a render.
121 s.mu.RLock()
122 tag := s.tmplTag
123 ts := s.templates
124 s.mu.RUnlock()
125
126 // Vars carry the second endpoint of a compare, so a moving base ref
127 // still changes the ETag.
128 etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery,
129 c.Commit, c.Vars["baseSHA"], tag) + `"`
130 w.Header().Set("ETag", etag)
131 if rt.Immutable && c.Commit != "" {
132 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
133 } else {
134 w.Header().Set("Cache-Control", "no-cache")
135 }
136 if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
137 w.WriteHeader(http.StatusNotModified)
138 return
139 }
140
141 body, err := rt.Render.Render(c, ts)
142 if err != nil {
143 s.fail(w, r, rt, err)
144 return
145 }
146 w.Header().Set("Content-Type", rt.Render.ContentType())
147 if hs, ok := rt.Render.(HeaderSetter); ok {
148 hs.Headers(c, w.Header())
149 }
150 w.Write(body)
151 }
152
153 func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
154 if rt.Scope == ScopeSite {
155 c := NewCtx(s.Site, nil, RepoInfo{}, "")
156 c.Store = s.cache
157 return c, nil
158 }
159
160 slug := r.PathValue("repo")
161 h, ok := s.repos[slug]
162 if !ok {
163 return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
164 }
165 repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
166
167 // {rest...} carries ref and path fused together; {rev} is a bare ref;
168 // {range} is "base..head" or "base...head".
169 ref, path := "", ""
170 var base string
171 dots := "2"
172
173 switch {
174 case r.PathValue("range") != "":
175 rng := r.PathValue("range")
176 var head string
177 if b, hd, ok := strings.Cut(rng, "..."); ok {
178 base, head, dots = b, hd, "3"
179 } else if b, hd, ok := strings.Cut(rng, ".."); ok {
180 base, head = b, hd
181 } else {
182 return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
183 }
184 ref = head
185 case r.PathValue("rest") != "":
186 ref, path = repo.SplitRefPath(r.PathValue("rest"))
187 case r.PathValue("rev") != "":
188 ref = r.PathValue("rev")
189 default:
190 ref = repo.HeadRef()
191 }
192
193 c := NewCtx(s.Site, repo, h.Info, ref)
194 c.Path = path
195 c.Store = s.cache
196
197 if !repo.Empty() {
198 sha, err := repo.Resolve(ref)
199 if err != nil {
200 return nil, Errorf(http.StatusNotFound, "%v", err)
201 }
202 c.Commit = sha
203 }
204 if base != "" {
205 baseSHA, err := repo.Resolve(base)
206 if err != nil {
207 return nil, Errorf(http.StatusNotFound, "%v", err)
208 }
209 c.Vars["base"], c.Vars["head"] = base, ref
210 c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, c.Commit
211 c.Vars["dots"] = dots
212 }
213 return c, nil
214 }
215
216 func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
217 code := statusOf(err)
218 if errors.Is(err, gitx.ErrTruncated) {
219 code = http.StatusRequestEntityTooLarge
220 }
221 if code >= 500 {
222 s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err)
223 } else {
224 s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
225 }
226 http.Error(w, http.StatusText(code), code)
227 }
228
229 func shortHash(parts ...string) string {
230 h := sha256.New()
231 for _, p := range parts {
232 h.Write([]byte(p))
233 h.Write([]byte{0})
234 }
235 return hex.EncodeToString(h.Sum(nil))[:16]
236 }