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