Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/core/route.go
cb5a7c7c656de5a381e0234208c552c0856e43e3
· 7.1 KB · 222 lines
raw
| 1 | package core |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "html/template" |
| 8 | "net/http" |
| 9 | ) |
| 10 | |
| 11 | // TemplateSet holds one independently-parsed template tree per view. |
| 12 | // Views are parsed separately because html/template has a single namespace |
| 13 | // for defined blocks -- two views both defining "main" would collide. |
| 14 | type TemplateSet map[string]*template.Template |
| 15 | |
| 16 | // Renderer turns a Ctx into bytes plus a content type. HTML pages and JSON |
| 17 | // endpoints are the same shape of thing; this is where they unify. |
| 18 | type Renderer interface { |
| 19 | Render(c *Ctx, ts TemplateSet) ([]byte, error) |
| 20 | ContentType() string |
| 21 | } |
| 22 | |
| 23 | // View renders an HTML template. The template calls modules via `mod`. |
| 24 | type View struct{ Template string } |
| 25 | |
| 26 | func (v View) ContentType() string { return "text/html; charset=utf-8" } |
| 27 | |
| 28 | func (v View) Render(c *Ctx, ts TemplateSet) ([]byte, error) { |
| 29 | t, ok := ts[v.Template] |
| 30 | if !ok { |
| 31 | return nil, fmt.Errorf("no such view %q", v.Template) |
| 32 | } |
| 33 | // Clone per render so the funcmap can close over this Ctx. |
| 34 | tt, err := t.Clone() |
| 35 | if err != nil { |
| 36 | return nil, err |
| 37 | } |
| 38 | tt = tt.Funcs(FuncMap(c)) |
| 39 | var buf bytes.Buffer |
| 40 | if err := tt.ExecuteTemplate(&buf, v.Template, c); err != nil { |
| 41 | return nil, fmt.Errorf("view %s: %w", v.Template, err) |
| 42 | } |
| 43 | return buf.Bytes(), nil |
| 44 | } |
| 45 | |
| 46 | // Endpoint builds the value that will be serialized. It must return a |
| 47 | // purpose-built DTO, never a raw module value -- see internal/api. |
| 48 | type Endpoint func(c *Ctx) (any, error) |
| 49 | |
| 50 | // JSON renders an API endpoint. |
| 51 | type JSON struct{ Build Endpoint } |
| 52 | |
| 53 | func (j JSON) ContentType() string { return "application/json; charset=utf-8" } |
| 54 | |
| 55 | func (j JSON) Render(c *Ctx, _ TemplateSet) ([]byte, error) { |
| 56 | v, err := j.Build(c) |
| 57 | if err != nil { |
| 58 | return nil, err |
| 59 | } |
| 60 | b, err := json.MarshalIndent(v, "", " ") |
| 61 | if err != nil { |
| 62 | return nil, err |
| 63 | } |
| 64 | return append(b, '\n'), nil |
| 65 | } |
| 66 | |
| 67 | // ------------------------------------------------------------------ routes |
| 68 | |
| 69 | // Route binds a renderer to a net/http pattern (Go 1.22 wildcards). |
| 70 | // |
| 71 | // /{repo}/tree/{rest...} -> r.PathValue("repo"), r.PathValue("rest") |
| 72 | // |
| 73 | // Scope decides how the Ctx is built before Render runs. |
| 74 | type Route struct { |
| 75 | Name string |
| 76 | Pattern string // e.g. "GET /{repo}/commit/{rev}" |
| 77 | Render Renderer |
| 78 | Scope Scope |
| 79 | // Immutable marks responses whose content cannot change for a given |
| 80 | // URL -- anything addressed by a commit SHA. Those get a year-long |
| 81 | // Cache-Control; ref-addressed URLs get revalidation instead. |
| 82 | Immutable bool |
| 83 | // NavLabel, when set on a ScopeRepo route, adds a tab for it to the |
| 84 | // repo-scoped navigation (see nav.go) -- the top tab bar and the |
| 85 | // page-nav chips both derive from this instead of hardcoding links. |
| 86 | NavLabel string |
| 87 | |
| 88 | // Request, when set, parses this route's path parameters. A nil parser |
| 89 | // uses the default {rest...}/{rev} handling. Parsers only split path |
| 90 | // text into ref candidates: core still calls gitx.Repo.Resolve, so user |
| 91 | // input never reaches git as raw ref syntax (see AGENTS invariant 1). |
| 92 | Request RequestFunc |
| 93 | |
| 94 | // PageSize, when greater than zero, enables ?ofs= pagination at that |
| 95 | // page size and folds the normalized offset into Ctx.Vars["ofs"]. |
| 96 | PageSize int |
| 97 | } |
| 98 | |
| 99 | // Request is the path-derived input a route hands back to core before any |
| 100 | // ref is resolved. Ref is the head commit; Range is set only for routes |
| 101 | // that compare two commits. |
| 102 | type Request struct { |
| 103 | Ref string |
| 104 | Path string |
| 105 | Range *Range |
| 106 | } |
| 107 | |
| 108 | // Range describes a base..head request. Ref on Request carries head. |
| 109 | type Range struct { |
| 110 | Base string |
| 111 | ThreeDot bool |
| 112 | } |
| 113 | |
| 114 | // RequestFunc parses route-specific path parameters. It must not invoke |
| 115 | // git or resolve refs itself. |
| 116 | type RequestFunc func(*http.Request) (Request, error) |
| 117 | |
| 118 | type Scope int |
| 119 | |
| 120 | const ( |
| 121 | ScopeRepo Scope = iota // resolves {repo}, and {rest...}/{rev} if present |
| 122 | ScopeSite // no repository in the URL |
| 123 | ) |
| 124 | |
| 125 | // Page sizes for routes that paginate via ?ofs=. Kept in core (not |
| 126 | // modules) so buildCtx can quantize an incoming ofs to a real page |
| 127 | // boundary without an import cycle. Routes declare the matching PageSize, |
| 128 | // and it must match the literal arg the log.html/graph.html template passes |
| 129 | // to `mod "log"`/`mod "graph"`. |
| 130 | const ( |
| 131 | LogPageSize = 20 |
| 132 | GraphPageSize = 150 |
| 133 | ) |
| 134 | |
| 135 | // httpError lets a module or endpoint choose the status code. |
| 136 | type httpError struct { |
| 137 | Code int |
| 138 | Err error |
| 139 | } |
| 140 | |
| 141 | func (e httpError) Error() string { return e.Err.Error() } |
| 142 | func (e httpError) Unwrap() error { return e.Err } |
| 143 | |
| 144 | func Errorf(code int, format string, a ...any) error { |
| 145 | return httpError{Code: code, Err: fmt.Errorf(format, a...)} |
| 146 | } |
| 147 | |
| 148 | func statusOf(err error) int { |
| 149 | var he httpError |
| 150 | if ok := asHTTPError(err, &he); ok { |
| 151 | return he.Code |
| 152 | } |
| 153 | return http.StatusInternalServerError |
| 154 | } |
| 155 | |
| 156 | // HeaderSetter lets a renderer add response headers. Optional: the server |
| 157 | // type-asserts for it after Render succeeds. |
| 158 | type HeaderSetter interface { |
| 159 | Headers(c *Ctx, h http.Header) |
| 160 | } |
| 161 | |
| 162 | // Raw serves file bytes straight out of the object database. |
| 163 | // |
| 164 | // SECURITY: a repository can contain an .html file with a <script> tag. If |
| 165 | // this served it as text/html on your own origin, any push would be stored |
| 166 | // XSS against the whole site -- which is exactly why GitHub puts raw content |
| 167 | // on raw.githubusercontent.com, a separate origin. Serving it from the same |
| 168 | // host is only safe if the response cannot be interpreted as a document: |
| 169 | // text/plain or octet-stream for everything the module did not validate, |
| 170 | // nosniff so the browser does not second-guess that, and a sandbox CSP as a |
| 171 | // backstop. The one exception is InlineContentTyper: a module that has |
| 172 | // checked the bytes may name a raster image type, which cannot execute and |
| 173 | // is still covered by nosniff. |
| 174 | type Raw struct{ Module string } |
| 175 | |
| 176 | func (Raw) ContentType() string { return "text/plain; charset=utf-8" } |
| 177 | |
| 178 | func (r Raw) Render(c *Ctx, _ TemplateSet) ([]byte, error) { |
| 179 | v, err := c.Mod(r.Module) |
| 180 | if err != nil { |
| 181 | return nil, err |
| 182 | } |
| 183 | b, ok := v.(interface{ Bytes() []byte }) |
| 184 | if !ok { |
| 185 | return nil, fmt.Errorf("raw: module %q is not raw-serving", r.Module) |
| 186 | } |
| 187 | return b.Bytes(), nil |
| 188 | } |
| 189 | |
| 190 | func (r Raw) Headers(c *Ctx, h http.Header) { |
| 191 | h.Set("X-Content-Type-Options", "nosniff") |
| 192 | h.Set("Content-Security-Policy", "default-src 'none'; sandbox") |
| 193 | h.Set("Content-Disposition", "inline") |
| 194 | |
| 195 | v, err := c.Mod(r.Module) |
| 196 | if err != nil { |
| 197 | return |
| 198 | } |
| 199 | // A module may declare a validated inline MIME (raster images). It is |
| 200 | // checked before IsBinary so an image is served as image/*, not |
| 201 | // octet-stream; only modules that vetted the bytes may return one, and |
| 202 | // the nosniff above keeps the declared type from being second-guessed. |
| 203 | if ct, ok := v.(InlineContentTyper); ok { |
| 204 | if s := ct.InlineContentType(); s != "" { |
| 205 | h.Set("Content-Type", s) |
| 206 | return |
| 207 | } |
| 208 | } |
| 209 | if bin, ok := v.(interface{ IsBinary() bool }); ok && bin.IsBinary() { |
| 210 | h.Set("Content-Type", "application/octet-stream") |
| 211 | h.Set("Content-Disposition", "attachment") |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // InlineContentTyper lets a raw-serving module declare a MIME type that is |
| 216 | // safe to render inline on this origin. It exists so core stays ignorant of |
| 217 | // formats: the module that knows how to validate image bytes is the module |
| 218 | // that names the type. An empty return keeps the text/plain or |
| 219 | // octet-stream-attachment default. |
| 220 | type InlineContentTyper interface { |
| 221 | InlineContentType() string |
| 222 | } |