v0.8.1 b8e893f

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
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}

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