From 042b9536e09c2c7cb6489a5d36dcf10567c1dd22 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Mon, 17 Aug 2026 22:34:10 +0200 Subject: [PATCH] Add pipeline log download endpoint (#6876) Co-authored-by: Codex Co-authored-by: Claude --- cmd/server/openapi/docs.go | 50 +++++ server/api/pipeline.go | 203 ++++++------------ server/api/pipeline_test.go | 82 ++++--- server/router/api.go | 21 +- server/router/middleware/session/pipeline.go | 68 ++++++ .../middleware/session/pipeline_test.go | 93 ++++++++ server/router/middleware/session/step.go | 68 ++++++ server/router/middleware/session/step_test.go | 93 ++++++++ web/src/assets/locales/en.json | 1 - .../components/repo/pipeline/PipelineLog.vue | 44 +--- 10 files changed, 496 insertions(+), 227 deletions(-) create mode 100644 server/router/middleware/session/pipeline.go create mode 100644 server/router/middleware/session/pipeline_test.go create mode 100644 server/router/middleware/session/step.go create mode 100644 server/router/middleware/session/step_test.go diff --git a/cmd/server/openapi/docs.go b/cmd/server/openapi/docs.go index 907a50b02..a19e4a806 100644 --- a/cmd/server/openapi/docs.go +++ b/cmd/server/openapi/docs.go @@ -2831,6 +2831,56 @@ const docTemplate = `{ } } }, + "/repos/{repo_id}/logs/{pipeline_number}/{step_id}/download": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "Pipeline logs" + ], + "summary": "Download logs for a pipeline step", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "description": "the repository id", + "name": "repo_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "the number of the pipeline", + "name": "pipeline_number", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "the step id", + "name": "step_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, "/repos/{repo_id}/move": { "post": { "produces": [ diff --git a/server/api/pipeline.go b/server/api/pipeline.go index 1a576232d..f232ace90 100644 --- a/server/api/pipeline.go +++ b/server/api/pipeline.go @@ -20,6 +20,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "mime" "net/http" "strconv" "strings" @@ -199,27 +201,14 @@ func GetPipelines(c *gin.Context) { // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func DeletePipeline(c *gin.Context) { - _store := store.FromContext(c) - - repo := session.Repo(c) - num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } + pl := session.Pipeline(c) if ok := pipelineDeleteAllowed(pl); !ok { c.String(http.StatusUnprocessableEntity, "Cannot delete pipeline with status %s", pl.Status) return } - err = store.FromContext(c).DeletePipeline(pl) + err := store.FromContext(c).DeletePipeline(pl) if err != nil { c.String(http.StatusInternalServerError, "Error deleting pipeline. %s", err) return @@ -295,34 +284,7 @@ func GetPipelineLastByBranch(c *gin.Context) { // @Param pipeline_number path int true "the number of the pipeline" // @Param step_id path int true "the step id" func GetStepLogs(c *gin.Context) { - _store := store.FromContext(c) - repo := session.Repo(c) - - // parse the pipeline number and step sequence number from - // the request parameter. - num, err := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } - - stepID, err := strconv.ParseInt(c.Params.ByName("step_id"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - step, err := _store.StepLoad(pl.ID, stepID) - if err != nil { - handleDBError(c, err) - return - } + step := session.Step(c) logs, err := server.Config.Services.LogStore.LogFind(step) if err != nil { @@ -333,6 +295,59 @@ func GetStepLogs(c *gin.Context) { c.JSON(http.StatusOK, logs) } +// DownloadStepLogs +// +// @Summary Download logs for a pipeline step +// @Router /repos/{repo_id}/logs/{pipeline_number}/{step_id}/download [get] +// @Produce plain +// @Success 200 {string} string +// @Tags Pipeline logs +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param repo_id path int true "the repository id" +// @Param pipeline_number path int true "the number of the pipeline" +// @Param step_id path int true "the step id" +func DownloadStepLogs(c *gin.Context) { + step := session.Step(c) + + logs, err := server.Config.Services.LogStore.LogFind(step) + if err != nil { + handleDBError(c, err) + return + } + + repo := session.Repo(c) + pl := session.Pipeline(c) + filename := sanitizeDownloadFilename(fmt.Sprintf("%s-%s-%d-%s.log", repo.Owner, repo.Name, pl.Number, step.Name)) + c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Header("X-Content-Type-Options", "nosniff") + c.Status(http.StatusOK) + + for i, entry := range logs { + if i > 0 { + if _, err = io.WriteString(c.Writer, "\n"); err != nil { + _ = c.Error(err) + return + } + } + if _, err = c.Writer.Write(entry.Data); err != nil { + _ = c.Error(err) + return + } + } +} + +func sanitizeDownloadFilename(filename string) string { + filename = strings.Map(func(r rune) rune { + if r < ' ' || r == '\x7f' || strings.ContainsRune(`<>:"/\|?*`, r) { + return '-' + } + return r + }, filename) + filename = strings.TrimRight(filename, ".") + return strings.TrimSpace(filename) +} + // DeleteStepLogs // // @Summary Delete step logs of a pipeline @@ -346,31 +361,7 @@ func GetStepLogs(c *gin.Context) { // @Param step_id path int true "the step id" func DeleteStepLogs(c *gin.Context) { _store := store.FromContext(c) - repo := session.Repo(c) - - pipelineNumber, err := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - _pipeline, err := _store.GetPipelineNumber(repo, pipelineNumber) - if err != nil { - handleDBError(c, err) - return - } - - stepID, err := strconv.ParseInt(c.Params.ByName("step_id"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - _step, err := _store.StepLoad(_pipeline.ID, stepID) - if err != nil { - handleDBError(c, err) - return - } + _step := session.Step(c) switch _step.State { case model.StatusRunning, model.StatusPending: @@ -378,7 +369,7 @@ func DeleteStepLogs(c *gin.Context) { return } - err = _store.LogDelete(_step) + err := _store.LogDelete(_step) if err != nil { handleDBError(c, err) return @@ -399,18 +390,7 @@ func DeleteStepLogs(c *gin.Context) { // @Param pipeline_number path int true "the number of the pipeline" func GetPipelineConfig(c *gin.Context) { _store := store.FromContext(c) - repo := session.Repo(c) - num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } + pl := session.Pipeline(c) configs, err := _store.ConfigsForPipeline(pl.ID) if err != nil { @@ -433,18 +413,8 @@ func GetPipelineConfig(c *gin.Context) { // @Param pipeline_number path int true "the number of the pipeline" func GetPipelineMetadata(c *gin.Context) { repo := session.Repo(c) - num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } - _store := store.FromContext(c) - currentPipeline, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } + currentPipeline := session.Pipeline(c) forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { @@ -483,13 +453,7 @@ func CancelPipeline(c *gin.Context) { return } - num, _ := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) - - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } + pl := session.Pipeline(c) if err := pipeline.Cancel(c, _forge, _store, repo, user, pl, &model.CancelInfo{ CanceledByUser: user.Login, @@ -515,15 +479,9 @@ func PostApproval(c *gin.Context) { _store = store.FromContext(c) repo = session.Repo(c) user = session.User(c) - num, _ = strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) + pl = session.Pipeline(c) ) - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } - newPipeline, err := pipeline.Approve(c, _store, pl, user, repo) if err != nil { handlePipelineErr(c, err) @@ -547,16 +505,10 @@ func PostDecline(c *gin.Context) { _store = store.FromContext(c) repo = session.Repo(c) user = session.User(c) - num, _ = strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) + pl = session.Pipeline(c) ) - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } - - pl, err = pipeline.Decline(c, _store, pl, user, repo) + pl, err := pipeline.Decline(c, _store, pl, user, repo) if err != nil { handlePipelineErr(c, err) } else { @@ -597,12 +549,7 @@ func GetPipelineQueue(c *gin.Context) { func PostPipeline(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) - - num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) - if err != nil { - _ = c.AbortWithError(http.StatusBadRequest, err) - return - } + pl := session.Pipeline(c) user, err := _store.GetUser(repo.UserID) if err != nil { @@ -610,12 +557,6 @@ func PostPipeline(c *gin.Context) { return } - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } - // refresh the token to make sure, pipeline.Restart can still obtain the pipeline config if necessary again refreshUserToken(c, user) @@ -676,15 +617,7 @@ func PostPipeline(c *gin.Context) { // @Param pipeline_number path int true "the number of the pipeline" func DeletePipelineLogs(c *gin.Context) { _store := store.FromContext(c) - - repo := session.Repo(c) - num, _ := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) - - pl, err := _store.GetPipelineNumber(repo, num) - if err != nil { - handleDBError(c, err) - return - } + pl := session.Pipeline(c) steps, err := _store.StepList(pl.ID) if err != nil { diff --git a/server/api/pipeline_test.go b/server/api/pipeline_test.go index 3af54d98f..836bcc12a 100644 --- a/server/api/pipeline_test.go +++ b/server/api/pipeline_test.go @@ -35,6 +35,7 @@ import ( queue_mocks "go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" config_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks" + log_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/log/mocks" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" registry_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks" secret_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks" @@ -125,47 +126,69 @@ func TestGetPipelines(t *testing.T) { }) } +func TestDownloadStepLogs(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &model.Repo{ID: 1, Owner: "woodpecker-ci", Name: "woodpecker"} + pipeline := &model.Pipeline{ID: 2, Number: 42} + step := &model.Step{ID: 3, PipelineID: pipeline.ID, Name: "build"} + logs := []*model.LogEntry{ + {StepID: step.ID, Line: 0, Data: []byte("first line")}, + {StepID: step.ID, Line: 1, Data: []byte("\x1b[31msecond line\x1b[0m")}, + } + + mockLogStore := log_mocks.NewMockService(t) + mockLogStore.On("LogFind", step).Return(logs, nil) + + originalLogStore := server.Config.Services.LogStore + server.Config.Services.LogStore = mockLogStore + t.Cleanup(func() { + server.Config.Services.LogStore = originalLogStore + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("repo", repo) + c.Set("pipeline", pipeline) + c.Set("step", step) + + DownloadStepLogs(c) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "attachment; filename=woodpecker-ci-woodpecker-42-build.log", w.Header().Get("Content-Disposition")) + assert.Equal(t, "first line\n\x1b[31msecond line\x1b[0m", w.Body.String()) +} + func TestDeletePipeline(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should delete pipeline", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) - mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil) mockStore.On("DeletePipeline", mock.Anything).Return(nil) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("store", mockStore) - c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} + c.Set("pipeline", fakePipeline) DeletePipeline(c) - mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) mockStore.AssertCalled(t, "DeletePipeline", mock.Anything) assert.Equal(t, http.StatusNoContent, c.Writer.Status()) }) - t.Run("should not delete without pipeline number", func(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - - DeletePipeline(c) - - assert.Equal(t, http.StatusBadRequest, c.Writer.Status()) - }) - t.Run("should not delete pending", func(t *testing.T) { fakePipeline := *fakePipeline fakePipeline.Status = model.StatusPending mockStore := store_mocks.NewMockStore(t) - mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(&fakePipeline, nil) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("store", mockStore) - c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} + c.Set("pipeline", &fakePipeline) DeletePipeline(c) - mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything) assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status()) }) @@ -191,17 +214,16 @@ func TestGetPipelineMetadata(t *testing.T) { server.Config.Services.Manager = mockManager mockStore := store_mocks.NewMockStore(t) - mockStore.On("GetPipelineNumber", mock.Anything, int64(2)).Return(fakePipeline, nil) mockStore.On("GetPipelineLastBefore", mock.Anything, mock.Anything, int64(2)).Return(prevPipeline, nil) t.Run("PipelineMetadata", func(t *testing.T) { t.Run("should get pipeline metadata", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} c.Set("store", mockStore) c.Set("forge", mockForge) c.Set("repo", fakeRepo) + c.Set("pipeline", fakePipeline) GetPipelineMetadata(c) @@ -215,31 +237,6 @@ func TestGetPipelineMetadata(t *testing.T) { assert.Equal(t, int64(2), response.Curr.Number) assert.Equal(t, int64(1), response.Prev.Number) }) - - t.Run("should return bad request for invalid pipeline number", func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "pipeline_number", Value: "invalid"}} - - GetPipelineMetadata(c) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("should return not found for non-existent pipeline", func(t *testing.T) { - mockStore := store_mocks.NewMockStore(t) - mockStore.On("GetPipelineNumber", mock.Anything, int64(3)).Return((*model.Pipeline)(nil), types.ErrRecordNotExist) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "pipeline_number", Value: "3"}} - c.Set("store", mockStore) - c.Set("repo", fakeRepo) - - GetPipelineMetadata(c) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) }) } @@ -258,7 +255,6 @@ func TestCancelPipeline(t *testing.T) { mockForge := forge_mocks.NewMockForge(t) mockStore := store_mocks.NewMockStore(t) - mockStore.On("GetPipelineNumber", fakeRepo, int64(2)).Return(runningPipeline, nil) mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{}, nil) mockStore.On("UpdatePipeline", mock.Anything).Return(nil) @@ -272,7 +268,7 @@ func TestCancelPipeline(t *testing.T) { c.Set("store", mockStore) c.Set("repo", fakeRepo) c.Set("user", fakeUser) - c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} + c.Set("pipeline", runningPipeline) CancelPipeline(c) diff --git a/server/router/api.go b/server/router/api.go index 0f059ce48..ce1cbeab7 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -113,22 +113,23 @@ func apiRoutes(e *gin.RouterGroup) { repo.GET("/pipelines", api.GetPipelines) repo.POST("/pipelines", session.MustPush, api.CreatePipeline) - repo.DELETE("/pipelines/:pipeline_number", session.MustRepoAdmin(), api.DeletePipeline) + repo.DELETE("/pipelines/:pipeline_number", session.MustRepoAdmin(), session.SetPipeline(), api.DeletePipeline) repo.GET("/pipelines/:pipeline_number", api.GetPipeline) - repo.GET("/pipelines/:pipeline_number/config", api.GetPipelineConfig) - repo.GET("/pipelines/:pipeline_number/metadata", session.MustPush, api.GetPipelineMetadata) + repo.GET("/pipelines/:pipeline_number/config", session.SetPipeline(), api.GetPipelineConfig) + repo.GET("/pipelines/:pipeline_number/metadata", session.MustPush, session.SetPipeline(), api.GetPipelineMetadata) // requires push permissions - repo.POST("/pipelines/:pipeline_number", session.MustPush, api.PostPipeline) - repo.POST("/pipelines/:pipeline_number/cancel", session.MustPush, api.CancelPipeline) - repo.POST("/pipelines/:pipeline_number/approve", session.MustPush, api.PostApproval) - repo.POST("/pipelines/:pipeline_number/decline", session.MustPush, api.PostDecline) + repo.POST("/pipelines/:pipeline_number", session.MustPush, session.SetPipeline(), api.PostPipeline) + repo.POST("/pipelines/:pipeline_number/cancel", session.MustPush, session.SetPipeline(), api.CancelPipeline) + repo.POST("/pipelines/:pipeline_number/approve", session.MustPush, session.SetPipeline(), api.PostApproval) + repo.POST("/pipelines/:pipeline_number/decline", session.MustPush, session.SetPipeline(), api.PostDecline) - repo.GET("/logs/:pipeline_number/:step_id", api.GetStepLogs) - repo.DELETE("/logs/:pipeline_number/:step_id", session.MustPush, api.DeleteStepLogs) + repo.GET("/logs/:pipeline_number/:step_id", session.SetPipeline(), session.SetStep(), api.GetStepLogs) + repo.GET("/logs/:pipeline_number/:step_id/download", session.SetPipeline(), session.SetStep(), api.DownloadStepLogs) + repo.DELETE("/logs/:pipeline_number/:step_id", session.MustPush, session.SetPipeline(), session.SetStep(), api.DeleteStepLogs) // requires push permissions - repo.DELETE("/logs/:pipeline_number", session.MustPush, api.DeletePipelineLogs) + repo.DELETE("/logs/:pipeline_number", session.MustPush, session.SetPipeline(), api.DeletePipelineLogs) // requires push permissions repo.GET("/secrets", session.MustPush, api.GetSecretList) diff --git a/server/router/middleware/session/pipeline.go b/server/router/middleware/session/pipeline.go new file mode 100644 index 000000000..8735d1e85 --- /dev/null +++ b/server/router/middleware/session/pipeline.go @@ -0,0 +1,68 @@ +// Copyright 2026 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package session + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/server/store" + "go.woodpecker-ci.org/woodpecker/v3/server/store/types" +) + +// Pipeline returns the pipeline resolved by SetPipeline. +func Pipeline(c *gin.Context) *model.Pipeline { + v, ok := c.Get("pipeline") + if !ok { + return nil + } + p, ok := v.(*model.Pipeline) + if !ok { + return nil + } + return p +} + +// SetPipeline resolves the `pipeline_number` path param within the repo of the +// request and stores the pipeline in the context. It must run after SetRepo. +func SetPipeline() gin.HandlerFunc { + return func(c *gin.Context) { + _store := store.FromContext(c) + repo := Repo(c) + + number, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + pipeline, err := _store.GetPipelineNumber(repo, number) + if err != nil { + if errors.Is(err, types.ErrRecordNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.Set("pipeline", pipeline) + c.Next() + } +} diff --git a/server/router/middleware/session/pipeline_test.go b/server/router/middleware/session/pipeline_test.go new file mode 100644 index 000000000..4c94d531c --- /dev/null +++ b/server/router/middleware/session/pipeline_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build test + +package session + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" + store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" + "go.woodpecker-ci.org/woodpecker/v3/server/store/types" +) + +func TestSetPipeline(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &model.Repo{ID: 1} + + newCtx := func(t *testing.T, pipelineNumber string) (*gin.Context, *httptest.ResponseRecorder, *store_mocks.MockStore) { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + mockStore := store_mocks.NewMockStore(t) + c.Set("store", mockStore) + c.Set("repo", repo) + c.Params = gin.Params{{Key: "pipeline_number", Value: pipelineNumber}} + return c, rec, mockStore + } + + t.Run("should resolve the pipeline of the repo", func(t *testing.T) { + pipeline := &model.Pipeline{ID: 3, Number: 2} + c, _, mockStore := newCtx(t, "2") + mockStore.On("GetPipelineNumber", repo, int64(2)).Return(pipeline, nil) + + SetPipeline()(c) + + require.False(t, c.IsAborted()) + assert.Equal(t, pipeline, Pipeline(c)) + }) + + t.Run("should reject a non numeric pipeline number", func(t *testing.T) { + c, rec, _ := newCtx(t, "latest") + + SetPipeline()(c) + + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, Pipeline(c)) + }) + + t.Run("should return not found for an unknown pipeline", func(t *testing.T) { + c, rec, mockStore := newCtx(t, "3") + mockStore.On("GetPipelineNumber", repo, int64(3)).Return((*model.Pipeline)(nil), types.ErrRecordNotExist) + + SetPipeline()(c) + + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Nil(t, Pipeline(c)) + }) + + t.Run("should return internal server error on store failure", func(t *testing.T) { + c, rec, mockStore := newCtx(t, "4") + mockStore.On("GetPipelineNumber", repo, int64(4)).Return((*model.Pipeline)(nil), errors.New("database on fire")) + + SetPipeline()(c) + + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Nil(t, Pipeline(c)) + }) +} diff --git a/server/router/middleware/session/step.go b/server/router/middleware/session/step.go new file mode 100644 index 000000000..1cfd50b52 --- /dev/null +++ b/server/router/middleware/session/step.go @@ -0,0 +1,68 @@ +// Copyright 2026 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package session + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/server/store" + "go.woodpecker-ci.org/woodpecker/v3/server/store/types" +) + +// Step returns the step resolved by SetStep. +func Step(c *gin.Context) *model.Step { + v, ok := c.Get("step") + if !ok { + return nil + } + s, ok := v.(*model.Step) + if !ok { + return nil + } + return s +} + +// SetStep resolves the `step_id` path param within the pipeline of the request +// and stores the step in the context. It must run after SetPipeline. +func SetStep() gin.HandlerFunc { + return func(c *gin.Context) { + _store := store.FromContext(c) + pipeline := Pipeline(c) + + stepID, err := strconv.ParseInt(c.Param("step_id"), 10, 64) + if err != nil { + _ = c.AbortWithError(http.StatusBadRequest, err) + return + } + + step, err := _store.StepLoad(pipeline.ID, stepID) + if err != nil { + if errors.Is(err, types.ErrRecordNotExist) { + c.AbortWithStatus(http.StatusNotFound) + return + } + _ = c.AbortWithError(http.StatusInternalServerError, err) + return + } + + c.Set("step", step) + c.Next() + } +} diff --git a/server/router/middleware/session/step_test.go b/server/router/middleware/session/step_test.go new file mode 100644 index 000000000..7946181c0 --- /dev/null +++ b/server/router/middleware/session/step_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build test + +package session + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" + store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" + "go.woodpecker-ci.org/woodpecker/v3/server/store/types" +) + +func TestSetStep(t *testing.T) { + gin.SetMode(gin.TestMode) + + pipeline := &model.Pipeline{ID: 7, Number: 2} + + newCtx := func(t *testing.T, stepID string) (*gin.Context, *httptest.ResponseRecorder, *store_mocks.MockStore) { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + mockStore := store_mocks.NewMockStore(t) + c.Set("store", mockStore) + c.Set("pipeline", pipeline) + c.Params = gin.Params{{Key: "step_id", Value: stepID}} + return c, rec, mockStore + } + + t.Run("should resolve the step of the pipeline", func(t *testing.T) { + step := &model.Step{ID: 3, PipelineID: pipeline.ID} + c, _, mockStore := newCtx(t, "3") + mockStore.On("StepLoad", pipeline.ID, int64(3)).Return(step, nil) + + SetStep()(c) + + require.False(t, c.IsAborted()) + assert.Equal(t, step, Step(c)) + }) + + t.Run("should reject a non numeric step id", func(t *testing.T) { + c, rec, _ := newCtx(t, "build") + + SetStep()(c) + + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, Step(c)) + }) + + t.Run("should return not found for an unknown step", func(t *testing.T) { + c, rec, mockStore := newCtx(t, "4") + mockStore.On("StepLoad", pipeline.ID, int64(4)).Return((*model.Step)(nil), types.ErrRecordNotExist) + + SetStep()(c) + + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Nil(t, Step(c)) + }) + + t.Run("should return internal server error on store failure", func(t *testing.T) { + c, rec, mockStore := newCtx(t, "5") + mockStore.On("StepLoad", pipeline.ID, int64(5)).Return((*model.Step)(nil), errors.New("database on fire")) + + SetStep()(c) + + assert.True(t, c.IsAborted()) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Nil(t, Step(c)) + }) +} diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index bcc0151fc..c9d058dff 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -227,7 +227,6 @@ "no_logs": "No logs", "pipeline": "Pipeline #{pipelineId}", "log_title": "Step Logs", - "log_download_error": "An error occurred while downloading the log file", "log_delete_confirm": "Do you really want to delete the step logs?", "log_delete_error": "An error occurred when deleting the step logs", "actions": { diff --git a/web/src/components/repo/pipeline/PipelineLog.vue b/web/src/components/repo/pipeline/PipelineLog.vue index 8ad4d8d03..38076894a 100644 --- a/web/src/components/repo/pipeline/PipelineLog.vue +++ b/web/src/components/repo/pipeline/PipelineLog.vue @@ -31,11 +31,10 @@ /> (); const stepSlug = computed(() => `${repo?.value.owner} - ${repo?.value.name} - ${pipeline.value.id} - ${stepId.value}`); const step = computed(() => pipeline.value && findStep(pipeline.value.workflows || [], stepId.value)); @@ -226,6 +227,9 @@ const stream = ref(); const log = ref(); const consoleElement = ref(); const fullscreen = ref(false); +const logDownloadUrl = computed( + () => `${config.rootPath}/api/repos/${repo.value.id}/logs/${pipeline.value.number}/${step.value?.id}/download`, +); const loadedLogs = computed(() => !!log.value); const hasLogs = computed( @@ -235,13 +239,10 @@ const hasLogs = computed( ); const autoScroll = useStorage('woodpecker:log-auto-scroll', true); const showActions = ref(false); -const downloadInProgress = ref(false); const ansiUp = ref(new AnsiUp()); ansiUp.value.use_classes = true; const logBuffer = ref([]); -const config = useConfig(); - const maxLineCount = config.maxPipelineLogLineCount; // TODO(2653): implement lazy-loading support const hasPushPermission = computed(() => repoPermissions?.value?.push); @@ -435,39 +436,6 @@ const flushLogs = debounce((scroll: boolean) => { } }, 500); -async function download() { - if (!repo?.value || !pipeline.value || !step.value) { - throw new Error('The repository, pipeline or step was undefined'); - } - let logs; - try { - downloadInProgress.value = true; - logs = await apiClient.getLogs(repo.value.id, pipeline.value.number, step.value.id); - } catch (e) { - notifications.notifyError(e as Error, i18n.t('repo.pipeline.log_download_error')); - return; - } finally { - downloadInProgress.value = false; - } - const fileURL = window.URL.createObjectURL( - new Blob([logs.map((line) => decode(line.data ?? '')).join('\n')], { - type: 'text/plain', - }), - ); - const fileLink = document.createElement('a'); - - fileLink.href = fileURL; - fileLink.setAttribute( - 'download', - `${repo.value.owner}-${repo.value.name}-${pipeline.value.number}-${step.value.name}.log`, - ); - document.body.appendChild(fileLink); - - fileLink.click(); - document.body.removeChild(fileLink); - window.URL.revokeObjectURL(fileURL); -} - async function loadLogs() { if (loadedStepSlug.value === stepSlug.value) { return;