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 | } |