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

internal/core/server.go

83aa601b98007ead72afeadb131a93d5694e04ca · 10.2 KB · 332 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 "gsg/internal/session"
18 )
19
20 type Server struct {
21 Site *Site
22 Routes []Route
23 Log *slog.Logger
24 Timeout time.Duration // per-request git budget
25 MaxGit int64 // per-command output cap, bytes
26
27 // Sessions verifies the gsg_user login cookie. Nil-safe: an unset
28 // Sessions makes every request resolve as anonymous, i.e. today's
29 // behavior.
30 Sessions *session.Manager
31
32 // TemplateDir, when set, re-parses templates on every request. Theme
33 // iteration without a restart; leave empty in production.
34 TemplateDir string
35 TemplateFS fs.FS
36
37 mu sync.RWMutex
38 templates TemplateSet
39 tmplTag string // changes when templates change; part of every ETag
40 cache Store
41 }
42
43 func NewServer(site *Site, routes []Route, tfs fs.FS, cacheSize int) (*Server, error) {
44 s := &Server{
45 Site: site, Routes: routes, TemplateFS: tfs,
46 Log: slog.Default(),
47 Timeout: 10 * time.Second,
48 MaxGit: 16 << 20, // headroom above modules.maxPreviewSize (10 MiB)
49 cache: NewLRU(cacheSize),
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 c.Nav = BuildNav(s.Routes, rt.Name, c.Info.Slug, c.Ref)
119
120 // ETag is derived from the resolved commit SHA, not the response body,
121 // so a conditional request costs one ref lookup instead of a render.
122 s.mu.RLock()
123 tag := s.tmplTag
124 ts := s.templates
125 s.mu.RUnlock()
126
127 // contentIdentity carries the commit, keyring, and every Var (e.g.
128 // compare's base ref) -- the same fragment module results are keyed
129 // on, so this can't drift out of sync with what CacheKey covers.
130 //
131 // c.User and the raw gsg_share cookie value are folded in here,
132 // deliberately not into contentIdentity itself: contentIdentity also
133 // drives Ctx.CacheKey, the cross-request module Store key, and module
134 // results (tree/blob/log/...) aren't user-dependent -- authorization
135 // happens in buildCtx's Lookup gate, not inside a module. Keying the
136 // shared LRU per grant would fragment it for no benefit. The ETag,
137 // unlike the module cache, must vary by identity: without c.User,
138 // logging in or out keeps the same ETag as the anonymous response and a
139 // browser's stale If-None-Match earns a 304 with the wrong body. The
140 // share cookie needs the same treatment for the same reason: two
141 // anonymous visitors holding different share grants both have
142 // c.User == "", so without it they'd collide on one ETag and one
143 // share-holder's listing could be served to another. The cookie's own
144 // signature bytes already uniquely identify the grant, and folding them
145 // into a hash never echoes them back in a header.
146 shareCookie := ""
147 if ck, err := r.Cookie(session.ShareCookieName); err == nil {
148 shareCookie = ck.Value
149 }
150 etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity(), c.User, shareCookie) + `"`
151 w.Header().Set("ETag", etag)
152 if c.Immutable {
153 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
154 } else {
155 // Vary: Cookie applies to every response here, not just personalized
156 // ones -- the *absence* of gsg_user is also a cache dimension. Without
157 // it on the anonymous branch too, a shared cache could store an
158 // anonymous response keyed on URL alone and later serve it back to a
159 // request that does carry a session cookie.
160 w.Header().Add("Vary", "Cookie")
161 if c.User != "" || len(c.SharedSlugs) > 0 {
162 // No shared/intermediary cache (proxy, CDN) may ever store this
163 // response, only the requesting browser -- otherwise a
164 // misbehaving shared cache could serve one user's hidden-repo
165 // listing to a different visitor. Vary: Cookie above is defense
166 // in depth for any cache that honors Vary but not
167 // Cache-Control: private.
168 w.Header().Set("Cache-Control", "private, no-cache")
169 } else {
170 w.Header().Set("Cache-Control", "no-cache")
171 }
172 }
173 if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
174 w.WriteHeader(http.StatusNotModified)
175 return
176 }
177
178 body, err := rt.Render.Render(c, ts)
179 if err != nil {
180 s.fail(w, r, rt, err)
181 return
182 }
183 w.Header().Set("Content-Type", rt.Render.ContentType())
184 if hs, ok := rt.Render.(HeaderSetter); ok {
185 hs.Headers(c, w.Header())
186 }
187 w.Write(body)
188 }
189
190 func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error) {
191 // Resolved once, before the scope branch, so the site-scoped early
192 // return (the index page -- the main surface a login or share link is
193 // meant to affect) doesn't skip it.
194 user := s.Sessions.Verify(r)
195 var sharedSlugs []string
196 var sharedIssuedAt time.Time
197 if claims, ok := s.Sessions.VerifyShareCookie(r); ok {
198 sharedSlugs, sharedIssuedAt = claims.Slugs, claims.IssuedAt()
199 }
200
201 if rt.Scope == ScopeSite {
202 c := NewCtx(s.Site, nil, RepoInfo{}, "")
203 c.Store = s.cache
204 c.User = user
205 c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
206 return c, nil
207 }
208
209 slug := r.PathValue("repo")
210 h, ok := s.Site.Lookup(slug)
211 if !ok {
212 return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
213 }
214 // Hidden repos are 404, indistinguishable from nonexistent, unless the
215 // resolved identity is explicitly allowed to see this one, either as an
216 // owner (gsg.visible-to) or via an unrevoked share grant.
217 if h.Info.Hidden && !h.Info.CanView(user) && !h.Info.SharedVisible(sharedSlugs, sharedIssuedAt) {
218 return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
219 }
220 repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
221
222 // {rest...} carries ref and path fused together; {rev} is a bare ref;
223 // {range} is "base..head" or "base...head".
224 ref, path := "", ""
225 var base string
226 dots := "2"
227
228 switch {
229 case r.PathValue("range") != "":
230 rng := r.PathValue("range")
231 var head string
232 if b, hd, ok := strings.Cut(rng, "..."); ok {
233 base, head, dots = b, hd, "3"
234 } else if b, hd, ok := strings.Cut(rng, ".."); ok {
235 base, head = b, hd
236 } else {
237 return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
238 }
239 ref = head
240 case r.PathValue("rest") != "":
241 ref, path = repo.SplitRefPath(r.PathValue("rest"))
242 case r.PathValue("rev") != "":
243 ref = r.PathValue("rev")
244 default:
245 ref = repo.HeadRef()
246 }
247
248 c := NewCtx(s.Site, repo, h.Info, ref)
249 c.Path = path
250 c.Store = s.cache
251 c.User = user
252 c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
253
254 if !repo.Empty() {
255 sha, err := repo.Resolve(ref)
256 if err != nil {
257 return nil, Errorf(http.StatusNotFound, "%v", err)
258 }
259 c.Commit = sha
260 }
261 if base != "" {
262 baseSHA, err := repo.Resolve(base)
263 if err != nil {
264 return nil, Errorf(http.StatusNotFound, "%v", err)
265 }
266 headSHA := c.Commit
267 // Two-dot compares are undirected as far as the caller is concerned:
268 // whichever ref is the ancestor should read as "base", regardless of
269 // which side of ".." it was typed on. Three-dot is left alone --
270 // its merge-base semantics are direction-sensitive by design.
271 if dots == "2" && baseSHA != headSHA {
272 if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc {
273 base, ref = ref, base
274 baseSHA, headSHA = headSHA, baseSHA
275 }
276 }
277 c.Ref, c.Commit = ref, headSHA
278 c.Vars["base"], c.Vars["head"] = base, ref
279 c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA
280 c.Vars["dots"] = dots
281 }
282 if rt.Name == "log" || rt.Name == "graph" {
283 pageSize := LogPageSize
284 if rt.Name == "graph" {
285 pageSize = GraphPageSize
286 }
287 if v := r.URL.Query().Get("ofs"); v != "" {
288 if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
289 ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key
290 c.Vars["ofs"] = strconv.Itoa(ofs)
291 }
292 }
293 }
294 // One source of truth for the immutable policy: the cache header in
295 // serve() reads the same field, so page chrome that must not be frozen
296 // into a year-long response is suppressed by exactly the condition that
297 // makes the response year-long.
298 c.Immutable = rt.Immutable && c.Commit != ""
299 return c, nil
300 }
301
302 func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
303 code := statusOf(err)
304 if errors.Is(err, gitx.ErrTruncated) {
305 code = http.StatusRequestEntityTooLarge
306 }
307 if code >= 500 {
308 s.Log.Error("render failed", "route", rt.Name, "path", r.URL.Path, "err", err)
309 } else {
310 s.Log.Info("request rejected", "route", rt.Name, "path", r.URL.Path, "status", code, "err", err)
311 }
312
313 // serve() sets ETag/Cache-Control before Render runs, assuming success.
314 // An error response must never inherit those, especially the
315 // year-long immutable Cache-Control on commit routes.
316 w.Header().Del("ETag")
317 w.Header().Set("Cache-Control", "no-store")
318
319 body, cType := renderErrorPage(code, s.Site.Name)
320 w.Header().Set("Content-Type", cType)
321 w.WriteHeader(code)
322 w.Write(body)
323 }
324
325 func shortHash(parts ...string) string {
326 h := sha256.New()
327 for _, p := range parts {
328 h.Write([]byte(p))
329 h.Write([]byte{0})
330 }
331 return hex.EncodeToString(h.Sum(nil))[:16]
332 }