v0.8.1 b8e893f

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 watch runs in a goroutine and hands each event to this one through
124	// an unbuffered channel: a visitor that reads slowly holds the watch in
125	// its send, falls behind the buffer of the use case, and gets a gap.
126	msgs := make(chan recvd)
127	started := make(chan struct{})
128	var startOnce sync.Once
129	start := func() { startOnce.Do(func() { close(started) }) }
130	finished := make(chan struct{})
131	params := ports.WatchParams{
132		Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
133		// The visitor is the client of the watch, for the limit per client.
134		Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
135		Ready:  start,
136	}
137	go func() {
138		defer close(finished)
139		defer close(msgs)
140		// net/http only recovers the handler goroutine: a panic in the watch
141		// would end the process. It ends this stream as an error instead.
142		defer func() {
143			if v := recover(); v != nil {
144				log.Error().Interface("panic", v).Msg("event watch panicked")
145				select {
146				case msgs <- recvd{err: fmt.Errorf("watch panicked: %v", v)}:
147				case <-ctx.Done():
148				}
149			}
150		}()
151		err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error {
152			start()
153			select {
154			case msgs <- recvd{ev: we}:
155				return nil
156			case <-ctx.Done():
157				return ctx.Err()
158			}
159		})
160		if err != nil && ctx.Err() == nil {
161			select {
162			case msgs <- recvd{err: err}:
163			case <-ctx.Done():
164			}
165		}
166	}()
167	defer func() {
168		cancel()
169		<-finished
170	}()
171
172	// The watch is registered (or refused, which ends it before): only then
173	// does the answer commit to 200.
174	var first *recvd
175	select {
176	case <-started:
177	case m, open := <-msgs:
178		if !open {
179			return // the server is stopping
180		}
181		if m.err != nil {
182			s.denyEvents(ctx, w, m.err)
183			return
184		}
185		first = &m
186	case <-ctx.Done():
187		return
188	}
189
190	rc := http.NewResponseController(w)
191	hdr := w.Header()
192	hdr.Set("Content-Type", "text/event-stream")
193	cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
194	if hdr.Get("Cache-Control") == noStore { // an invalid cookie was cleared
195		hdr.Set("Cache-Control", noStore+", no-transform")
196	}
197	hdr.Set("X-Accel-Buffering", "no")
198	_ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
199	w.WriteHeader(http.StatusOK)
200	out := &sseWriter{w: w, rc: rc}
201	if err := out.raw(": connected\n\n"); err != nil {
202		return
203	}
204
205	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")}
206	handle := func(m recvd) bool {
207		if m.err != nil {
208			if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
209				log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended")
210			}
211			return false
212		}
213		if !liveEvent(m.ev) {
214			return true // nothing to show, not even an id to resume after
215		}
216		// An owner's stream ends when the session does, before it sends
217		// anything more: an event can carry what only the owner may see, and
218		// waiting for the next heartbeat would leave a window.
219		if SignedIn(ctx) && !s.sessionStillValid(ctx, r) {
220			return false
221		}
222		rctx, rcancel := context.WithTimeout(ctx, requestTimeout)
223		frags, err := st.render(rctx, m.ev)
224		rcancel()
225		if err != nil {
226			if ctx.Err() != nil {
227				return false
228			}
229			// What the page shows can no longer be told from the events:
230			// have it fetch itself again.
231			log.Warn().Err(err).Str("topic", topic).Msg("render live fragment")
232			frags = gap
233		}
234		return out.send(m.ev.ID, frags) == nil
235	}
236	if first != nil && !handle(*first) {
237		return
238	}
239
240	beat := s.cfg.Heartbeat
241	if beat <= 0 {
242		beat = DefaultHeartbeat
243	}
244	tick := time.NewTicker(beat)
245	defer tick.Stop()
246	for {
247		select {
248		case m, open := <-msgs:
249			if !open || !handle(m) {
250				return
251			}
252		case <-tick.C:
253			// A stream opened by the owner ends when the session does
254			// (sign out everywhere, expiry): the page reconnects as it can.
255			if SignedIn(ctx) && !s.sessionStillValid(ctx, r) {
256				return
257			}
258			if out.raw(": heartbeat\n\n") != nil {
259				return
260			}
261		case <-ctx.Done():
262			return
263		}
264	}
265}
266
267// recvd is a message of the Watch stream, or how it failed.
268type recvd struct {
269	ev  ports.WatchEvent
270	err error
271}
272
273// canSee checks that the caller of the request may see the repository named in
274// a topic: one it may not is as missing as one that does not exist.
275func (s *Server) canSee(ctx context.Context, rawName string) error {
276	name, err := domain.ParseRepoName(rawName)
277	if err != nil {
278		return err
279	}
280	_, err = s.cfg.Repos.Get(ctx, principal(ctx), name)
281	return err
282}
283
284// denyStream answers a request that gets no stream.
285func denyStream(w http.ResponseWriter, status int, msg string) {
286	w.Header().Set("Cache-Control", "no-store")
287	http.Error(w, msg, status)
288}
289
290// denyEvents maps an error of a use case, before the stream started, to a
291// status. Not found and invalid are 404 whatever the reason: a private
292// repository is a missing one.
293func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) {
294	switch {
295	case errors.Is(err, context.Canceled):
296		// The visitor is gone.
297	case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
298		denyStream(w, http.StatusNotFound, "not found")
299	case errors.Is(err, domain.ErrResourceExhausted):
300		w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
301		denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later")
302	case errors.Is(err, domain.ErrUnauthenticated):
303		denyStream(w, http.StatusUnauthorized, "unauthorized")
304	default:
305		log := zerowrap.FromCtx(ctx)
306		log.Error().Err(err).Msg("open event stream")
307		denyStream(w, http.StatusInternalServerError, "internal error")
308	}
309}
310
311// fragment is one SSE event to send: the name and the HTML of the fragment.
312type fragment struct{ event, html string }
313
314// sseWriter writes events to a visitor, flushing each, under a deadline of
315// its own so that the write timeout of the server cannot end the stream.
316type sseWriter struct {
317	w  http.ResponseWriter
318	rc *http.ResponseController
319}
320
321func (o *sseWriter) raw(s string) error {
322	_ = o.rc.SetWriteDeadline(time.Now().Add(writeWindow))
323	if _, err := io.WriteString(o.w, s); err != nil {
324		return err
325	}
326	return o.rc.Flush()
327}
328
329// send writes the fragments of one Watch event under its id. An event that
330// has no fragment still carries its id, so that a reconnection resumes after
331// it.
332func (o *sseWriter) send(id string, frags []fragment) error {
333	if len(frags) == 0 {
334		if id == "" {
335			return nil
336		}
337		return o.raw(formatEvent(id, "", "", false))
338	}
339	for _, f := range frags {
340		if err := o.raw(formatEvent(id, f.event, f.html, true)); err != nil {
341			return err
342		}
343	}
344	return nil
345}
346
347// formatEvent is one SSE event. Each line of data is a "data:" field, and a
348// browser joins them back with newlines.
349func formatEvent(id, event, data string, hasData bool) string {
350	var b strings.Builder
351	if event != "" {
352		b.WriteString("event: " + event + "\n")
353	}
354	if id != "" {
355		b.WriteString("id: " + id + "\n")
356	}
357	if hasData {
358		data = strings.ReplaceAll(strings.ReplaceAll(data, "\r\n", "\n"), "\r", "\n")
359		for line := range strings.SplitSeq(data, "\n") {
360			b.WriteString("data: " + line + "\n")
361		}
362	}
363	b.WriteString("\n")
364	return b.String()
365}
366
367// liveURL is the stream a page follows: the topic and, for a repository
368// page, what it shows (see the comment on events).
369func liveURL(topic, query string) string {
370	u := "/-/events?topic=" + url.QueryEscape(topic)
371	if query != "" {
372		u += "&" + query
373	}
374	return u
375}

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 3

Tags 44

↑↓ moveenter openesc close