v0.6.0 d5cc8fe

paths.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close