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

internal/core/server.go

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