package api import ( "context" "database/sql" "errors" "net/http" "strconv" "strings" "time" ) // Summary queries never load log bodies; history cannot inflate a page response. const buildSummaryColumns = "id,version,status,error,image_id,created_at,requested_by,COALESCE(recipe_rev,''),COALESCE(platform,''),COALESCE(finished_at,''),experimental" func scanBuildSummary(row interface{ Scan(...any) error }, b *buildRecord) error { return row.Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental) } func (a *API) listBuilds(w http.ResponseWriter, r *http.Request) { limit, offset := 20, 0 for key, dest := range map[string]*int{"limit": &limit, "offset": &offset} { if values, ok := r.URL.Query()[key]; ok { if len(values) != 1 { fail(w, 400, "invalid pagination") return } value, err := strconv.Atoi(values[0]) if err != nil || value < 0 || (key == "limit" && (value < 1 || value > 100)) { fail(w, 400, "invalid pagination") return } *dest = value } } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() buildQueue.Lock() err := a.ensureBuilds() buildQueue.Unlock() if err != nil { fail(w, 500, "database error") return } rows, err := a.s.DB.QueryContext(ctx, "SELECT "+buildSummaryColumns+" FROM image_builds ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?", limit+1, offset) if err != nil { fail(w, 500, "database error") return } result := []buildRecord{} for rows.Next() { var b buildRecord if err = scanBuildSummary(rows, &b); err != nil { break } result = append(result, b) } if err == nil { err = rows.Err() } rows.Close() if err != nil { fail(w, 500, "database error") return } hasMore := len(result) > limit if hasMore { result = result[:limit] } if err := a.reconcileBuilds(ctx, result); err != nil { fail(w, 502, "docker image reconciliation failed") return } a.startBuildWorker() w.Header().Set("X-Has-More", strconv.FormatBool(hasMore)) writeJSON(w, result) } func (a *API) buildDetail(w http.ResponseWriter, r *http.Request) { if _, _, err := a.user(r); err != nil { fail(w, 401, "authentication required") return } if r.Method != http.MethodGet { fail(w, 405, "method not allowed") return } id := strings.TrimPrefix(r.URL.Path, "/api/builds/") if id == "" || len(id) > 128 || strings.Contains(id, "/") { fail(w, 404, "build not found") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() buildQueue.Lock() err := a.ensureBuilds() buildQueue.Unlock() var b buildRecord if err == nil { err = a.s.DB.QueryRowContext(ctx, "SELECT "+buildSummaryColumns+",logs FROM image_builds WHERE id=?", id).Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.Logs) } if errors.Is(err, sql.ErrNoRows) { fail(w, 404, "build not found") return } if err != nil { fail(w, 500, "database error") return } b.Logs = sanitizeBuildLog(b.Logs) writeJSON(w, b) }