detached fda3c2b

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

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 4

Tags 45

↑↓ moveenter openesc close