| @@ -1,12 +1,20 @@ |
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "io" |
| 4 | 8 | "net/http" |
| 5 | 9 | "net/url" |
| 6 | 10 | "slices" |
| 7 | 11 | "strconv" |
| 12 | "strings" |
| 8 | 13 | |
| 9 | 14 | "gitbay.org/gitbay/internal/control" |
| 15 | "gitbay.org/gitbay/internal/protocol" |
| 16 | "gitbay.org/gitbay/internal/store" |
| 17 | "gitbay.org/gitbay/internal/web" |
| 10 | 18 | ) |
| 11 | 19 | |
| 12 | 20 | // buildFilter is the builds page's GET filter: branch, status and job, |
| @@ -295,13 +303,86 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
| 295 | 303 | s.notFound(w, r) |
| 296 | 304 | return |
| 297 | 305 | } |
| 298 | | log, _, _ := s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) |
| 306 | v := buildView{repoPage: p, Build: b, CanWrite: s.canWriteRepo(r, p.Repo), Notice: s.takeFlash(w, r)} |
| 307 | if (b.Status == "pending" || b.Status == "running") && r.URL.Query().Get("follow") != "0" { |
| 308 | s.streamBuild(w, r, v, viewer, n) |
| 309 | return |
| 310 | } |
| 311 | v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) |
| 312 | s.render(w, "build.html", v) |
| 313 | } |
| 299 | 314 | |
| 300 | | s.render(w, "build.html", struct { |
| 301 | | repoPage |
| 302 | | Build control.BuildOut |
| 303 | | Log string |
| 304 | | CanWrite bool |
| 305 | | Notice string |
| 306 | | }{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) |
| 315 | type buildView struct { |
| 316 | repoPage |
| 317 | Build control.BuildOut |
| 318 | Log string |
| 319 | Live bool |
| 320 | CanWrite bool |
| 321 | Notice string |
| 322 | } |
| 323 | |
| 324 | // liveLogMarker stands in for the log when build.html is rendered for a |
| 325 | // live build; streamBuild splits the page there and streams the log into |
| 326 | // the gap. Git refs, paths and job names cannot hold the control byte. |
| 327 | const liveLogMarker = "\x1elive-log\x1e" |
| 328 | |
| 329 | // streamBuild writes the build page with the log following the build: |
| 330 | // the page up to the log, then build log --follow escaped and flushed as |
| 331 | // it arrives, then the outcome and the rest of the page. |
| 332 | func (s *Server) streamBuild(w http.ResponseWriter, r *http.Request, v buildView, viewer store.User, n string) { |
| 333 | v.Live, v.Log = true, liveLogMarker |
| 334 | var buf bytes.Buffer |
| 335 | if err := web.Render(&buf, "build.html", v); err != nil { |
| 336 | http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError) |
| 337 | return |
| 338 | } |
| 339 | head, tail, ok := strings.Cut(buf.String(), liveLogMarker) |
| 340 | if !ok || !strings.HasPrefix(tail, "</pre>") { |
| 341 | http.Error(w, "template error: build.html has no live log slot", http.StatusInternalServerError) |
| 342 | return |
| 343 | } |
| 344 | tail = strings.TrimPrefix(tail, "</pre>") |
| 345 | |
| 346 | h := w.Header() |
| 347 | h.Set("Content-Type", "text/html; charset=utf-8") |
| 348 | h.Set("Cache-Control", "no-store") |
| 349 | h.Set("X-Accel-Buffering", "no") |
| 350 | rc := http.NewResponseController(w) |
| 351 | io.WriteString(w, head) |
| 352 | rc.Flush() |
| 353 | |
| 354 | path := v.Repo.Path() |
| 355 | msg, code := s.runControlStream(viewer, []string{"build", "log", path, n, "--follow"}, |
| 356 | htmlStream{w: w, rc: rc}, r.Context().Done()) |
| 357 | if code == protocol.ExitDenied { |
| 358 | // The follow cap: the stored log once, and why it is not live. |
| 359 | log, _, _ := s.runControl(viewer, []string{"build", "log", path, n}) |
| 360 | template.HTMLEscape(w, []byte(log)) |
| 361 | } |
| 362 | io.WriteString(w, "</pre>") |
| 363 | switch { |
| 364 | case code == protocol.ExitOK: |
| 365 | var b control.BuildOut |
| 366 | if _, ok := s.runControlInto(viewer, []string{"build", "show", path, n}, &b); ok { |
| 367 | fmt.Fprintf(w, `<p class="notice" role="status">build finished: %s</p>`, template.HTMLEscapeString(b.Status)) |
| 368 | } |
| 369 | case code == protocol.ExitDenied: |
| 370 | fmt.Fprintf(w, `<p class="error" role="alert">%s</p>`, template.HTMLEscapeString(msg)) |
| 371 | } |
| 372 | io.WriteString(w, tail) |
| 373 | } |
| 374 | |
| 375 | // htmlStream escapes each chunk of a streamed log into the page and |
| 376 | // flushes it, so the browser draws it as it arrives. |
| 377 | type htmlStream struct { |
| 378 | w io.Writer |
| 379 | rc *http.ResponseController |
| 380 | } |
| 381 | |
| 382 | func (h htmlStream) Write(p []byte) (int, error) { |
| 383 | template.HTMLEscape(h.w, p) |
| 384 | if err := h.rc.Flush(); err != nil { |
| 385 | return 0, err |
| 386 | } |
| 387 | return len(p), nil |
| 307 | 388 | } |