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