v0.9.0 dd8597a

events.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"net/url"
12	"strconv"
13	"strings"
14	"sync"
15	"time"
16
17	"github.com/bnema/zerowrap"
18
19	"bnema.dev/ermite/internal/clientlimit"
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22)
23
24// Live updates. A page that can change under the eyes of its reader opens
25// GET /-/events (the Live component of the hint badge). The handler follows
26// EventService.Watch as the principal of the request and turns each event into
27// the HTML fragments the page shows, sent as Server-Sent Events:
28//
29//	GET /-/events?topic=home
30//	GET /-/events?topic=repo:<name>&view=summary|refs
31//	GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
32//	GET /-/events?topic=repo:<name>&view=mrs
33//	GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34//	GET /-/events?topic=repo:<name>&view=actions
35//	GET /-/events?topic=repo:<name>&view=run&n=<run id>
36//	GET /-/events?topic=repo:<name>&view=job&job=<job id>
37//
38// topic is the Watch topic. view says which fragments of a repository the
39// page shows, so that the server renders only those (summary when absent).
40// On the commits page, ref is the branch it lists and head the commit it
41// lists first: the banner counts the commits between that head and the new
42// tip, so it stays right however many pushes came in and however often the
43// stream reconnected. A Last-Event-ID header resumes the stream
44// (resume_after).
45//
46// Events, each with the id of the Watch event it comes from:
47//
48//	home-list       data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr>
49//	                for a repository that is deleted or no longer visible
50//	repo-last-push  data: <dd id="last-push-<name>">…</dd>
51//	refs-list       data: <tbody id="ref-rows">…</tbody>, and again with
52//	                <dl id="refs-kv">…</dl>, the facts of the rail
53//	commits-head    data: <div id="commits-head">N new commits — reload</div>
54//	mr-notice       data: <div id="mr-notice">…</div>, on the merge request pages:
55//	                something changed, with the link that reloads the page
56//	ci-run          data: <tr id="run-<id>">…</tr> (the list of runs) or
57//	                <div id="run-body">…</div> (one run)
58//	ci-job          data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59//	                itself is never sent in this stream: the page follows it on its own
60//	                feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line>
61//	gap             data: gap; events were lost, or what the page shows is gone or
62//	                changed: the page fetches itself again (ermiteLive in ermite.js)
63//
64// The data of an event is one fragment, a top-level element with an id, one
65// "data:" line per line of HTML; the page swaps the element that has that id
66// (ermiteLive.swap). The slots of the banners (commits-head, mr-notice) are
67// status regions that stay in the page: only their children are replaced, so
68// that a screen reader announces the banner.
69// An event that changes nothing on the page is still sent as an id alone, so
70// that a reconnection resumes after it.
71
72// DefaultHeartbeat is how often an idle stream gets a comment line, so that
73// proxies keep it open and a dead peer is noticed.
74const DefaultHeartbeat = 25 * time.Second
75
76const (
77	// writeWindow bounds one write to the visitor, whatever the write timeout
78	// of the server is.
79	writeWindow = 30 * time.Second
80	// retryAfter is the delay asked from a visitor refused for lack of room.
81	retryAfter = 10
82)
83
84// parseTopic reads "home" or "repo:<name>"; repo is "" for home.
85func parseTopic(topic string) (repo string, ok bool) {
86	if topic == "home" {
87		return "", true
88	}
89	if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
90		return name, true
91	}
92	return "", false
93}
94
95// events is GET /-/events.
96func (s *Server) events(w http.ResponseWriter, r *http.Request) {
97	if s.cfg.Events == nil {
98		http.NotFound(w, r)
99		return
100	}
101	q := r.URL.Query()
102	topic := q.Get("topic")
103	repo, ok := parseTopic(topic)
104	if !ok {
105		denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
106		return
107	}
108	ctx, cancel := context.WithCancel(r.Context())
109	defer cancel()
110	log := zerowrap.FromCtx(ctx)
111
112	if repo != "" {
113		// A repository the visitor cannot see is a missing one (AC2).
114		cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
115		err := s.canSee(cctx, repo)
116		ccancel()
117		if err != nil {
118			s.denyEvents(ctx, w, err)
119			return
120		}
121	}
122
123	// The commit of the run of a job page is read once here: the annotations of
124	// the live job fragments link to files at it, and it never changes, so a
125	// job update does not read the run again. A run that is not there or not
126	// readable refuses the stream like a missing repository.
127	var sha string
128	if repo != "" && q.Get("view") == "job" && s.cfg.Runs != nil {
129		cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
130		var err error
131		sha, err = s.jobRunSha(cctx, repo, q.Get("job"))
132		ccancel()
133		if err != nil {
134			s.denyEvents(ctx, w, err)
135			return
136		}
137	}
138
139	// The watch runs in a goroutine and hands each event to this one through
140	// an unbuffered channel: a visitor that reads slowly holds the watch in
141	// its send, falls behind the buffer of the use case, and gets a gap.
142	msgs := make(chan recvd)
143	started := make(chan struct{})
144	var startOnce sync.Once
145	start := func() { startOnce.Do(func() { close(started) }) }
146	finished := make(chan struct{})
147	params := ports.WatchParams{
148		Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
149		// The visitor is the client of the watch, for the limit per client.
150		Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
151		Ready:  start,
152	}
153	go func() {
154		defer close(finished)
155		defer close(msgs)
156		// net/http only recovers the handler goroutine: a panic in the watch
157		// would end the process. It ends this stream as an error instead.
158		defer func() {
159			if v := recover(); v != nil {
160				log.Error().Interface("panic", v).Msg("event watch panicked")
161				select {
162				case msgs <- recvd{err: fmt.Errorf("watch panicked: %v", v)}:
163				case <-ctx.Done():
164				}
165			}
166		}()
167		err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error {
168			start()
169			select {
170			case msgs <- recvd{ev: we}:
171				return nil
172			case <-ctx.Done():
173				return ctx.Err()
174			}
175		})
176		if err != nil && ctx.Err() == nil {
177			select {
178			case msgs <- recvd{err: err}:
179			case <-ctx.Done():
180			}
181		}
182	}()
183	defer func() {
184		cancel()
185		<-finished
186	}()
187
188	// The watch is registered (or refused, which ends it before): only then
189	// does the answer commit to 200.
190	var first *recvd
191	select {
192	case <-started:
193	case m, open := <-msgs:
194		if !open {
195			return // the server is stopping
196		}
197		if m.err != nil {
198			s.denyEvents(ctx, w, m.err)
199			return
200		}
201		first = &m
202	case <-ctx.Done():
203		return
204	}
205
206	rc := http.NewResponseController(w)
207	hdr := w.Header()
208	hdr.Set("Content-Type", "text/event-stream")
209	cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
210	if hdr.Get("Cache-Control") == noStore { // an invalid cookie was cleared
211		hdr.Set("Cache-Control", noStore+", no-transform")
212	}
213	hdr.Set("X-Accel-Buffering", "no")
214	_ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
215	w.WriteHeader(http.StatusOK)
216	out := &sseWriter{w: w, rc: rc}
217	if err := out.raw(": connected\n\n"); err != nil {
218		return
219	}
220
221	st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
222	handle := func(m recvd) bool {
223		if m.err != nil {
224			if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
225				log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended")
226			}
227			return false
228		}
229		if !liveEvent(m.ev) {
230			return true // nothing to show, not even an id to resume after
231		}
232		// An owner's stream ends when the session does, before it sends
233		// anything more: an event can carry what only the owner may see, and
234		// waiting for the next heartbeat would leave a window.
235		if SignedIn(ctx) && !s.sessionStillValid(ctx, r) {
236			return false
237		}
238		rctx, rcancel := context.WithTimeout(ctx, requestTimeout)
239		frags, err := st.render(rctx, m.ev)
240		rcancel()
241		if err != nil {
242			if ctx.Err() != nil {
243				return false
244			}
245			// What the page shows can no longer be told from the events:
246			// have it fetch itself again.
247			log.Warn().Err(err).Str("topic", topic).Msg("render live fragment")
248			frags = gap
249		}
250		return out.send(m.ev.ID, frags) == nil
251	}
252	if first != nil && !handle(*first) {
253		return
254	}
255
256	beat := s.cfg.Heartbeat
257	if beat <= 0 {
258		beat = DefaultHeartbeat
259	}
260	tick := time.NewTicker(beat)
261	defer tick.Stop()
262	for {
263		select {
264		case m, open := <-msgs:
265			if !open || !handle(m) {
266				return
267			}
268		case <-tick.C:
269			// A stream opened by the owner ends when the session does
270			// (sign out everywhere, expiry): the page reconnects as it can.
271			if SignedIn(ctx) && !s.sessionStillValid(ctx, r) {
272				return
273			}
274			if out.raw(": heartbeat\n\n") != nil {
275				return
276			}
277		case <-ctx.Done():
278			return
279		}
280	}
281}
282
283// recvd is a message of the Watch stream, or how it failed.
284type recvd struct {
285	ev  ports.WatchEvent
286	err error
287}
288
289// canSee checks that the caller of the request may see the repository named in
290// a topic: one it may not is as missing as one that does not exist.
291func (s *Server) canSee(ctx context.Context, rawName string) error {
292	name, err := domain.ParseRepoName(rawName)
293	if err != nil {
294		return err
295	}
296	_, err = s.cfg.Repos.Get(ctx, principal(ctx), name)
297	return err
298}
299
300// denyStream answers a request that gets no stream.
301func denyStream(w http.ResponseWriter, status int, msg string) {
302	w.Header().Set("Cache-Control", "no-store")
303	http.Error(w, msg, status)
304}
305
306// denyEvents maps an error of a use case, before the stream started, to a
307// status. Not found and invalid are 404 whatever the reason: a private
308// repository is a missing one.
309func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) {
310	switch {
311	case errors.Is(err, context.Canceled):
312		// The visitor is gone.
313	case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
314		denyStream(w, http.StatusNotFound, "not found")
315	case errors.Is(err, domain.ErrResourceExhausted):
316		w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
317		denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later")
318	case errors.Is(err, domain.ErrUnauthenticated):
319		denyStream(w, http.StatusUnauthorized, "unauthorized")
320	default:
321		log := zerowrap.FromCtx(ctx)
322		log.Error().Err(err).Msg("open event stream")
323		denyStream(w, http.StatusInternalServerError, "internal error")
324	}
325}
326
327// fragment is one SSE event to send: the name and the HTML of the fragment.
328type fragment struct{ event, html string }
329
330// sseWriter writes events to a visitor, flushing each, under a deadline of
331// its own so that the write timeout of the server cannot end the stream.
332type sseWriter struct {
333	w  http.ResponseWriter
334	rc *http.ResponseController
335}
336
337func (o *sseWriter) raw(s string) error {
338	_ = o.rc.SetWriteDeadline(time.Now().Add(writeWindow))
339	if _, err := io.WriteString(o.w, s); err != nil {
340		return err
341	}
342	return o.rc.Flush()
343}
344
345// send writes the fragments of one Watch event under its id. An event that
346// has no fragment still carries its id, so that a reconnection resumes after
347// it.
348func (o *sseWriter) send(id string, frags []fragment) error {
349	if len(frags) == 0 {
350		if id == "" {
351			return nil
352		}
353		return o.raw(formatEvent(id, "", "", false))
354	}
355	for _, f := range frags {
356		if err := o.raw(formatEvent(id, f.event, f.html, true)); err != nil {
357			return err
358		}
359	}
360	return nil
361}
362
363// formatEvent is one SSE event. Each line of data is a "data:" field, and a
364// browser joins them back with newlines.
365func formatEvent(id, event, data string, hasData bool) string {
366	var b strings.Builder
367	if event != "" {
368		b.WriteString("event: " + event + "\n")
369	}
370	if id != "" {
371		b.WriteString("id: " + id + "\n")
372	}
373	if hasData {
374		data = strings.ReplaceAll(strings.ReplaceAll(data, "\r\n", "\n"), "\r", "\n")
375		for line := range strings.SplitSeq(data, "\n") {
376			b.WriteString("data: " + line + "\n")
377		}
378	}
379	b.WriteString("\n")
380	return b.String()
381}
382
383// liveURL is the stream a page follows: the topic and, for a repository
384// page, what it shows (see the comment on events).
385func liveURL(topic, query string) string {
386	u := "/-/events?topic=" + url.QueryEscape(topic)
387	if query != "" {
388		u += "&" + query
389	}
390	return u
391}

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