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
15 "bnema.dev/ermite/internal/domain"
16)
17
18// errBuildPanicked is what the waiters of a path list get when its build
19// panicked.
20var errBuildPanicked = errors.New("web: building the path list failed")
21
22// pathList is the JSON body of GET /{repo}/paths/{sha}.
23type pathList struct {
24 Paths []string `json:"paths"`
25 Truncated bool `json:"truncated"`
26}
27
28// paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for
29// the file finder (files.js). The commit must be a full id; the answer never
30// changes for it, but who may read it does, so every request is authorized
31// first (ResolveRevision runs the access rule, as the caller of the request) and only then served from the
32// cache of lists. Caching follows raw: a visitor revalidates every time, with
33// an ETag that makes it a 304 (answered before the list is loaded, so it never
34// waits for a build); the owner's copy is private and never stored. The ETag
35// differs for the gzip and the plain variant.
36func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
37 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
38 defer cancel()
39 sha := r.PathValue("sha")
40 if !isFullID(sha) {
41 s.fail(ctx, w, r, errNotFound)
42 return
43 }
44 repo, err := domain.ParseRepoName(r.PathValue("repo"))
45 if err != nil {
46 s.fail(ctx, w, r, err)
47 return
48 }
49 res, err := s.resolveRevision(ctx, repo, sha)
50 if err != nil {
51 s.fail(ctx, w, r, err)
52 return
53 }
54 // A ref can be named like a commit id: only the commit itself is served,
55 // since the cache is keyed by it.
56 if res.CommitID.String() != sha {
57 s.fail(ctx, w, r, errNotFound)
58 return
59 }
60
61 // The validator names the variant that would be served: the gzip bytes
62 // and the plain JSON are different representations.
63 gz := acceptsGzip(r.Header)
64 etag := `"paths-` + sha + `"`
65 if gz {
66 etag = `"paths-` + sha + `-gz"`
67 }
68 // Revalidated every time, as raw is: a repository can turn private, and a
69 // shared cache must not keep serving its files. The ETag keeps it cheap.
70 validators := func(h http.Header) {
71 h.Set("Vary", "Accept-Encoding")
72 h.Set("ETag", etag)
73 cacheHeaders(ctx, h, "no-cache", sessionCache)
74 }
75 // Access is settled: a revalidation is answered without the list, so it
76 // never waits for a build.
77 if etagMatches(r.Header.Get("If-None-Match"), etag) {
78 validators(w.Header())
79 w.WriteHeader(http.StatusNotModified)
80 return
81 }
82 body, err := s.pathCache.load(ctx, repo.String(), sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) })
83 if err != nil {
84 s.fail(ctx, w, r, err)
85 return
86 }
87 // Authorized, not modified and loaded: all that is left is to send it.
88 validators(w.Header())
89 if err := writePathList(w, r, body, gz); err != nil {
90 s.fail(ctx, w, r, err)
91 }
92}
93
94// writePathList sends a list (gzipped JSON, as the cache keeps it): as it is
95// to a client that accepts gzip, unzipped to the others. The validators are
96// already set. It fails only before anything is written.
97func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) error {
98 var plain *gzip.Reader
99 if !gz {
100 var err error
101 if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil {
102 return err
103 }
104 }
105 h := w.Header()
106 h.Set("Content-Type", "application/json")
107 h.Set("X-Content-Type-Options", "nosniff")
108 if gz {
109 h.Set("Content-Encoding", "gzip")
110 h.Set("Content-Length", strconv.Itoa(len(body)))
111 w.WriteHeader(http.StatusOK)
112 if r.Method != http.MethodHead {
113 _, _ = w.Write(body)
114 }
115 return nil
116 }
117 w.WriteHeader(http.StatusOK)
118 if r.Method != http.MethodHead {
119 _, _ = io.Copy(w, plain)
120 }
121 return nil
122}
123
124// buildPaths lists the paths of a commit and returns them as gzipped JSON.
125// The build is shared by the requests that wait for it, so it does not stop
126// when the request that started it ends.
127func (s *Server) buildPaths(ctx context.Context, repo domain.RepoName, sha string) ([]byte, error) {
128 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
129 defer cancel()
130 list, err := s.listPaths(ctx, repo, sha)
131 if err != nil {
132 return nil, err
133 }
134 if list.CommitID.String() != sha {
135 return nil, errNotFound
136 }
137 paths := list.Paths
138 if paths == nil {
139 paths = []string{}
140 }
141 var buf bytes.Buffer
142 zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
143 if err != nil {
144 return nil, err
145 }
146 enc := json.NewEncoder(zw)
147 enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are
148 if err := enc.Encode(pathList{Paths: paths, Truncated: list.Truncated}); err != nil {
149 return nil, err
150 }
151 if err := zw.Close(); err != nil {
152 return nil, err
153 }
154 // Not buf.Bytes(): the buffer's spare capacity would stay in the cache
155 // without being counted in its budget. (bytes.Clone may round the
156 // capacity up, so the copy is exact.)
157 body := make([]byte, buf.Len())
158 copy(body, buf.Bytes())
159 return body, nil
160}