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

internal/core/route.go

main · 7.7 KB · 232 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 // CacheableAnon opts a route into Server's in-memory response cache for
84 // anonymous, shareless requests (see Server.cacheEligible) -- a full
85 // rendered response is stored and replayed to the next matching
86 // request within Ctx.cacheMaxAge, without touching git at all. Reserved
87 // for the small-HTML page routes most likely to be a spike's hot page;
88 // left off routes whose bodies can be large or unboundedly numerous
89 // (blob, raw, compare -- see routes.go) and the JSON api-* endpoints,
90 // to keep this pass's scope and memory footprint bounded: respCache is
91 // an LRU bounded by entry count, not bytes.
92 CacheableAnon bool
93 // NavLabel, when set on a ScopeRepo route, adds a tab for it to the
94 // repo-scoped navigation (see nav.go) -- the top tab bar and the
95 // page-nav chips both derive from this instead of hardcoding links.
96 NavLabel string
97
98 // Request, when set, parses this route's path parameters. A nil parser
99 // uses the default {rest...}/{rev} handling. Parsers only split path
100 // text into ref candidates: core still calls gitx.Repo.Resolve, so user
101 // input never reaches git as raw ref syntax (see AGENTS invariant 1).
102 Request RequestFunc
103
104 // PageSize, when greater than zero, enables ?ofs= pagination at that
105 // page size and folds the normalized offset into Ctx.Vars["ofs"].
106 PageSize int
107 }
108
109 // Request is the path-derived input a route hands back to core before any
110 // ref is resolved. Ref is the head commit; Range is set only for routes
111 // that compare two commits.
112 type Request struct {
113 Ref string
114 Path string
115 Range *Range
116 }
117
118 // Range describes a base..head request. Ref on Request carries head.
119 type Range struct {
120 Base string
121 ThreeDot bool
122 }
123
124 // RequestFunc parses route-specific path parameters. It must not invoke
125 // git or resolve refs itself.
126 type RequestFunc func(*http.Request) (Request, error)
127
128 type Scope int
129
130 const (
131 ScopeRepo Scope = iota // resolves {repo}, and {rest...}/{rev} if present
132 ScopeSite // no repository in the URL
133 )
134
135 // Page sizes for routes that paginate via ?ofs=. Kept in core (not
136 // modules) so buildCtx can quantize an incoming ofs to a real page
137 // boundary without an import cycle. Routes declare the matching PageSize,
138 // and it must match the literal arg the log.html/graph.html template passes
139 // to `mod "log"`/`mod "graph"`.
140 const (
141 LogPageSize = 20
142 GraphPageSize = 150
143 )
144
145 // httpError lets a module or endpoint choose the status code.
146 type httpError struct {
147 Code int
148 Err error
149 }
150
151 func (e httpError) Error() string { return e.Err.Error() }
152 func (e httpError) Unwrap() error { return e.Err }
153
154 func Errorf(code int, format string, a ...any) error {
155 return httpError{Code: code, Err: fmt.Errorf(format, a...)}
156 }
157
158 func statusOf(err error) int {
159 var he httpError
160 if ok := asHTTPError(err, &he); ok {
161 return he.Code
162 }
163 return http.StatusInternalServerError
164 }
165
166 // HeaderSetter lets a renderer add response headers. Optional: the server
167 // type-asserts for it after Render succeeds.
168 type HeaderSetter interface {
169 Headers(c *Ctx, h http.Header)
170 }
171
172 // Raw serves file bytes straight out of the object database.
173 //
174 // SECURITY: a repository can contain an .html file with a <script> tag. If
175 // this served it as text/html on your own origin, any push would be stored
176 // XSS against the whole site -- which is exactly why GitHub puts raw content
177 // on raw.githubusercontent.com, a separate origin. Serving it from the same
178 // host is only safe if the response cannot be interpreted as a document:
179 // text/plain or octet-stream for everything the module did not validate,
180 // nosniff so the browser does not second-guess that, and a sandbox CSP as a
181 // backstop. The one exception is InlineContentTyper: a module that has
182 // checked the bytes may name a raster image type, which cannot execute and
183 // is still covered by nosniff.
184 type Raw struct{ Module string }
185
186 func (Raw) ContentType() string { return "text/plain; charset=utf-8" }
187
188 func (r Raw) Render(c *Ctx, _ TemplateSet) ([]byte, error) {
189 v, err := c.Mod(r.Module)
190 if err != nil {
191 return nil, err
192 }
193 b, ok := v.(interface{ Bytes() []byte })
194 if !ok {
195 return nil, fmt.Errorf("raw: module %q is not raw-serving", r.Module)
196 }
197 return b.Bytes(), nil
198 }
199
200 func (r Raw) Headers(c *Ctx, h http.Header) {
201 h.Set("X-Content-Type-Options", "nosniff")
202 h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
203 h.Set("Content-Disposition", "inline")
204
205 v, err := c.Mod(r.Module)
206 if err != nil {
207 return
208 }
209 // A module may declare a validated inline MIME (raster images). It is
210 // checked before IsBinary so an image is served as image/*, not
211 // octet-stream; only modules that vetted the bytes may return one, and
212 // the nosniff above keeps the declared type from being second-guessed.
213 if ct, ok := v.(InlineContentTyper); ok {
214 if s := ct.InlineContentType(); s != "" {
215 h.Set("Content-Type", s)
216 return
217 }
218 }
219 if bin, ok := v.(interface{ IsBinary() bool }); ok && bin.IsBinary() {
220 h.Set("Content-Type", "application/octet-stream")
221 h.Set("Content-Disposition", "attachment")
222 }
223 }
224
225 // InlineContentTyper lets a raw-serving module declare a MIME type that is
226 // safe to render inline on this origin. It exists so core stays ignorant of
227 // formats: the module that knows how to validate image bytes is the module
228 // that names the type. An empty return keeps the text/plain or
229 // octet-stream-attachment default.
230 type InlineContentTyper interface {
231 InlineContentType() string
232 }