1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bytes"
7 "compress/gzip"
8 "context"
9 "encoding/json"
10 "errors"
11 "io"
12 "net/http"
13 "strconv"
14 "strings"
15
16 "connectrpc.com/connect"
17
18 pb "bnema.dev/ermite-api/gen/ermite"
19)
20
21// errBuildPanicked is what the waiters of a path list get when its build
22// panicked.
23var errBuildPanicked = errors.New("web: building the path list failed")
24
25// pathList is the JSON body of GET /{repo}/paths/{sha}.
26type pathList struct {
27 Paths []string `json:"paths"`
28 Truncated bool `json:"truncated"`
29}
30
31// paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for
32// the file finder (files.js). The commit must be a full id; the answer never
33// changes for it, but who may read it does, so every request is authorized
34// first (ResolveRevision runs the access rule) and only then served from the
35// cache of lists. Caching follows raw: a visitor revalidates every time, with
36// an ETag that makes it a 304 (answered before the list is loaded, so it never
37// waits for a build); the owner's copy is private and never stored. The ETag
38// differs for the gzip and the plain variant.
39func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
40 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
41 defer cancel()
42 repo, sha := r.PathValue("repo"), r.PathValue("sha")
43 if !isFullID(sha) {
44 s.fail(ctx, w, r, errNotFound)
45 return
46 }
47 res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: sha}))
48 if err != nil {
49 s.fail(ctx, w, r, err)
50 return
51 }
52 // A ref can be named like a commit id: only the commit itself is served,
53 // since the cache is keyed by it.
54 if res.Msg.GetCommitId() != sha {
55 s.fail(ctx, w, r, errNotFound)
56 return
57 }
58
59 // The validator names the variant that would be served: the gzip bytes
60 // and the plain JSON are different representations.
61 gz := acceptsGzip(r.Header.Get("Accept-Encoding"))
62 etag := `"paths-` + sha + `"`
63 if gz {
64 etag = `"paths-` + sha + `-gz"`
65 }
66 // Revalidated every time, as raw is: a repository can turn private, and a
67 // shared cache must not keep serving its files. The ETag keeps it cheap.
68 validators := func(h http.Header) {
69 h.Set("Vary", "Accept-Encoding")
70 h.Set("ETag", etag)
71 cacheHeaders(ctx, h, "no-cache", sessionCache)
72 }
73 // Access is settled: a revalidation is answered without the list, so it
74 // never waits for a build.
75 if etagMatches(r.Header.Get("If-None-Match"), etag) {
76 validators(w.Header())
77 w.WriteHeader(http.StatusNotModified)
78 return
79 }
80 body, err := s.pathCache.load(ctx, repo, sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) })
81 if err != nil {
82 s.fail(ctx, w, r, err)
83 return
84 }
85 // Authorized, not modified and loaded: all that is left is to send it.
86 validators(w.Header())
87 if err := writePathList(w, r, body, gz); err != nil {
88 s.fail(ctx, w, r, err)
89 }
90}
91
92// writePathList sends a list (gzipped JSON, as the cache keeps it): as it is
93// to a client that accepts gzip, unzipped to the others. The validators are
94// already set. It fails only before anything is written.
95func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) error {
96 var plain *gzip.Reader
97 if !gz {
98 var err error
99 if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil {
100 return err
101 }
102 }
103 h := w.Header()
104 h.Set("Content-Type", "application/json")
105 h.Set("X-Content-Type-Options", "nosniff")
106 if gz {
107 h.Set("Content-Encoding", "gzip")
108 h.Set("Content-Length", strconv.Itoa(len(body)))
109 w.WriteHeader(http.StatusOK)
110 if r.Method != http.MethodHead {
111 _, _ = w.Write(body)
112 }
113 return nil
114 }
115 w.WriteHeader(http.StatusOK)
116 if r.Method != http.MethodHead {
117 _, _ = io.Copy(w, plain)
118 }
119 return nil
120}
121
122// buildPaths lists the paths of a commit and returns them as gzipped JSON.
123// The build is shared by the requests that wait for it, so it does not stop
124// when the request that started it ends.
125func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, error) {
126 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
127 defer cancel()
128 res, err := s.cfg.Git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: sha}))
129 if err != nil {
130 return nil, err
131 }
132 if res.Msg.GetCommitId() != sha {
133 return nil, errNotFound
134 }
135 paths := res.Msg.GetPaths()
136 if paths == nil {
137 paths = []string{}
138 }
139 var buf bytes.Buffer
140 zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
141 if err != nil {
142 return nil, err
143 }
144 enc := json.NewEncoder(zw)
145 enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are
146 if err := enc.Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {
147 return nil, err
148 }
149 if err := zw.Close(); err != nil {
150 return nil, err
151 }
152 // Not buf.Bytes(): the buffer's spare capacity would stay in the cache
153 // without being counted in its budget. (bytes.Clone may round the
154 // capacity up, so the copy is exact.)
155 body := make([]byte, buf.Len())
156 copy(body, buf.Bytes())
157 return body, nil
158}
159
160// acceptsGzip reports whether an Accept-Encoding header allows gzip.
161func acceptsGzip(h string) bool {
162 for f := range strings.SplitSeq(h, ",") {
163 name, params, _ := strings.Cut(strings.TrimSpace(f), ";")
164 if !strings.EqualFold(strings.TrimSpace(name), "gzip") {
165 continue
166 }
167 if q, ok := strings.CutPrefix(strings.ReplaceAll(strings.TrimSpace(params), " ", ""), "q="); ok {
168 if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 {
169 return false
170 }
171 }
172 return true
173 }
174 return false
175}
176
177// etagMatches reports whether an If-None-Match header lists etag (weak
178// comparison, or "*").
179func etagMatches(header, etag string) bool {
180 for f := range strings.SplitSeq(header, ",") {
181 f = strings.TrimSpace(f)
182 if f == "*" || strings.TrimPrefix(f, "W/") == etag {
183 return true
184 }
185 }
186 return false
187}