From a5de7842c294aeb44cc26820b73113ad83b6ddea Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 25 Feb 2026 20:21:45 +0100 Subject: [PATCH] Add Actions artifact preview with devtest coverage --- routers/web/devtest/mock_actions.go | 209 +++++++++ routers/web/repo/actions/actions.go | 1 + routers/web/repo/actions/view.go | 396 ++++++++++++++++-- routers/web/web.go | 5 + templates/repo/actions/artifact_preview.tmpl | 79 ++++ templates/repo/actions/view_component.tmpl | 1 + .../actions_artifact_preview_test.go | 94 +++++ web_src/js/components/RepoActionView.vue | 20 +- web_src/js/features/repo-actions.ts | 1 + 9 files changed, 776 insertions(+), 30 deletions(-) create mode 100644 templates/repo/actions/artifact_preview.tmpl create mode 100644 tests/integration/actions_artifact_preview_test.go diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0fb2a35824..0c76fa6034 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -4,21 +4,132 @@ package devtest import ( + "archive/zip" + "fmt" + "html/template" + "io" mathRand "math/rand/v2" "net/http" + "net/url" + "path" "slices" "strconv" "strings" "time" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/web/repo/actions" "code.gitea.io/gitea/services/context" ) +type mockArtifactFile struct { + Path string + Content string +} + +type mockArtifactPreviewTemplateData struct { + ArtifactName string + Files []mockArtifactPreviewTemplateFile + PreviewURL string + PreviewRaw string + DownloadURL string + SelectedPath string +} + +type mockArtifactPreviewTemplateFile struct { + Path string + Selected bool +} + +var mockActionsArtifactFiles = map[string][]mockArtifactFile{ + "artifact-b": { + { + Path: "report.txt", + Content: "artifact-b report", + }, + }, + "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": { + { + Path: "index.html", + Content: ` + + +

Mock Artifact Preview

+

artifact-really-loooooong

+ +`, + }, + { + Path: "logs/output.txt", + Content: "mock logs", + }, + }, +} + +var mockArtifactPreviewTemplate = template.Must(template.New("mock-artifact-preview").Parse(` + + + + Artifact Preview + + + +

Preview: {{.ArtifactName}}

+

Reload | Download ZIP

+
+
+ {{range .Files}} + {{.Path}} + {{end}} +
+
+ {{if .SelectedPath}} + + {{else}} +

No files

+ {{end}} +
+
+ +`)) + +func normalizeMockArtifactPath(path string) string { + path = util.PathJoinRelX(path) + if path == "." { + return "" + } + return path +} + +func getMockArtifactFiles(name string) ([]mockArtifactFile, bool) { + files, ok := mockActionsArtifactFiles[name] + return files, ok +} + +func chooseMockArtifactPath(files []mockArtifactFile, requestedPath string) string { + if len(files) == 0 { + return "" + } + for _, file := range files { + if file.Path == requestedPath { + return requestedPath + } + } + return files[0].Path +} + type generateMockStepsLogOptions struct { mockCountFirst int mockCountGeneral int @@ -227,3 +338,101 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine } } + +func MockActionsArtifactDownload(ctx *context.Context) { + artifactName := ctx.PathParam("artifact_name") + files, ok := getMockArtifactFiles(artifactName) + if !ok { + ctx.NotFound(nil) + return + } + + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) + writer := zip.NewWriter(ctx.Resp) + defer writer.Close() + for _, file := range files { + w, err := writer.Create(file.Path) + if err != nil { + ctx.ServerError("writer.Create", err) + return + } + if _, err := io.WriteString(w, file.Content); err != nil { + ctx.ServerError("io.WriteString", err) + return + } + } +} + +func MockActionsArtifactPreview(ctx *context.Context) { + runID := ctx.PathParamInt64("run") + artifactName := ctx.PathParam("artifact_name") + files, ok := getMockArtifactFiles(artifactName) + if !ok { + ctx.NotFound(nil) + return + } + + selectedPath := chooseMockArtifactPath(files, normalizeMockArtifactPath(ctx.Req.URL.Query().Get("path"))) + templateFiles := make([]mockArtifactPreviewTemplateFile, 0, len(files)) + for _, file := range files { + templateFiles = append(templateFiles, mockArtifactPreviewTemplateFile{ + Path: file.Path, + Selected: file.Path == selectedPath, + }) + } + + previewURL := fmt.Sprintf("%s/devtest/repo-action-view/runs/%d/artifacts/%s/preview", setting.AppSubURL, runID, url.PathEscape(artifactName)) + previewRawURL := previewURL + "/raw" + downloadURL := fmt.Sprintf("%s/devtest/repo-action-view/runs/%d/artifacts/%s", setting.AppSubURL, runID, url.PathEscape(artifactName)) + data := mockArtifactPreviewTemplateData{ + ArtifactName: artifactName, + Files: templateFiles, + PreviewURL: previewURL, + PreviewRaw: previewRawURL, + DownloadURL: downloadURL, + SelectedPath: selectedPath, + } + + ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := mockArtifactPreviewTemplate.Execute(ctx.Resp, data); err != nil { + ctx.ServerError("mockArtifactPreviewTemplate.Execute", err) + return + } +} + +func MockActionsArtifactPreviewRaw(ctx *context.Context) { + artifactName := ctx.PathParam("artifact_name") + files, ok := getMockArtifactFiles(artifactName) + if !ok { + ctx.NotFound(nil) + return + } + + selectedPath := chooseMockArtifactPath(files, normalizeMockArtifactPath(ctx.Req.URL.Query().Get("path"))) + if selectedPath == "" { + ctx.NotFound(nil) + return + } + + var selectedFile *mockArtifactFile + for i := range files { + if files[i].Path == selectedPath { + selectedFile = &files[i] + break + } + } + if selectedFile == nil { + ctx.NotFound(nil) + return + } + + if path.Ext(selectedFile.Path) == ".html" { + ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + httplib.ServeContentByReader(ctx.Req, ctx.Resp, int64(len(selectedFile.Content)), strings.NewReader(selectedFile.Content), &httplib.ServeHeaderOptions{ + Filename: selectedFile.Path, + ContentType: "text/html", + }) + return + } + common.ServeContentByReader(ctx.Base, selectedFile.Path, int64(len(selectedFile.Content)), strings.NewReader(selectedFile.Content)) +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 988d2d0a99..ed094bb37d 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -36,6 +36,7 @@ const ( tplListActions templates.TplName = "repo/actions/list" tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs" tplViewActions templates.TplName = "repo/actions/view" + tplArtifactPreviewAction templates.TplName = "repo/actions/artifact_preview" ) type WorkflowInfo struct { diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 6b3e95f3da..93718f5855 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -5,6 +5,7 @@ package actions import ( "archive/zip" + "bytes" "compress/gzip" "context" "errors" @@ -13,6 +14,7 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "time" @@ -26,10 +28,13 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/util/filebuffer" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/common" actions_service "code.gitea.io/gitea/services/actions" @@ -124,6 +129,11 @@ type ArtifactsViewItem struct { Status string `json:"status"` } +type ArtifactPreviewFile struct { + Path string + Selected bool +} + type ViewResponse struct { Artifacts []*ArtifactsViewItem `json:"artifacts"` @@ -678,6 +688,362 @@ func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.A return run, jobs } +func getRunAndUploadedArtifacts(ctx *context_module.Context, runIndex int64, artifactName string) (*actions_model.ActionRun, []*actions_model.ActionArtifact, bool) { + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.HTTPError(http.StatusNotFound, err.Error()) + return nil, nil, false + } + ctx.ServerError("GetRunByIndex", err) + return nil, nil, false + } + + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RunID: run.ID, + ArtifactName: artifactName, + }) + if err != nil { + ctx.ServerError("FindArtifacts", err) + return nil, nil, false + } + if len(artifacts) == 0 { + ctx.HTTPError(http.StatusNotFound, "artifact not found") + return nil, nil, false + } + + for _, art := range artifacts { + if art.Status != actions_model.ArtifactStatusUploadConfirmed { + ctx.HTTPError(http.StatusNotFound, "artifact not found") + return nil, nil, false + } + } + + run.Repo = ctx.Repo.Repository + return run, artifacts, true +} + +func normalizeArtifactPreviewPath(path string) string { + path = util.PathJoinRelX(path) + if path == "." { + return "" + } + return path +} + +func artifactPreviewFallbackPath(artifact *actions_model.ActionArtifact) string { + path := normalizeArtifactPreviewPath(artifact.ArtifactPath) + if path != "" { + return path + } + return artifact.ArtifactName +} + +func choosePreviewPath(paths []string, requested string) string { + if len(paths) == 0 { + return "" + } + if util.SliceContainsString(paths, requested) { + return requested + } + return paths[0] +} + +func listPreviewPathsForLegacyArtifacts(artifacts []*actions_model.ActionArtifact) []string { + paths := make([]string, 0, len(artifacts)) + seen := make(map[string]struct{}, len(artifacts)) + for _, artifact := range artifacts { + path := artifactPreviewFallbackPath(artifact) + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func openArtifactV4ZipReader(artifact *actions_model.ActionArtifact) (*filebuffer.FileBackedBuffer, *zip.Reader, error) { + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + return nil, nil, err + } + defer f.Close() + + buf := filebuffer.New(int(setting.UI.MaxDisplayFileSize), "") + if _, err := io.Copy(buf, f); err != nil { + _ = buf.Close() + return nil, nil, err + } + + reader, err := zip.NewReader(buf, buf.Size()) + if err != nil { + _ = buf.Close() + return nil, nil, err + } + return buf, reader, nil +} + +func listArtifactV4ZipFiles(reader *zip.Reader) ([]string, map[string]*zip.File) { + paths := make([]string, 0, len(reader.File)) + files := make(map[string]*zip.File, len(reader.File)) + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + path := normalizeArtifactPreviewPath(file.Name) + if path == "" { + continue + } + if _, ok := files[path]; ok { + continue + } + files[path] = file + paths = append(paths, path) + } + sort.Strings(paths) + return paths, files +} + +func listPreviewPathsForV4Artifact(artifact *actions_model.ActionArtifact) ([]string, error) { + buf, reader, err := openArtifactV4ZipReader(artifact) + if err != nil { + if errors.Is(err, zip.ErrFormat) { + return []string{artifactPreviewFallbackPath(artifact)}, nil + } + return nil, err + } + defer buf.Close() + + paths, _ := listArtifactV4ZipFiles(reader) + return paths, nil +} + +func listPreviewPaths(artifacts []*actions_model.ActionArtifact) ([]string, error) { + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + return listPreviewPathsForV4Artifact(artifacts[0]) + } + return listPreviewPathsForLegacyArtifacts(artifacts), nil +} + +func isPreviewableArtifactType(st typesniffer.SniffedType) bool { + return st.IsText() || st.IsPDF() +} + +func setArtifactPreviewCSP(ctx *context_module.Context, st typesniffer.SniffedType) { + if st.GetMimeType() == "text/html" { + ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + } +} + +func previewArtifactByReader(ctx *context_module.Context, path string, size int64, reader io.Reader) { + buf := make([]byte, typesniffer.SniffContentSize) + n, err := util.ReadAtMost(reader, buf) + if err != nil { + ctx.ServerError("ReadAtMost", err) + return + } + if n < 0 { + n = 0 + } + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + if !isPreviewableArtifactType(st) { + ctx.HTTPError(http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type") + return + } + setArtifactPreviewCSP(ctx, st) + + stream := io.MultiReader(bytes.NewReader(buf), reader) + if st.GetMimeType() == "text/html" { + httplib.ServeContentByReader(ctx.Req, ctx.Resp, size, stream, &httplib.ServeHeaderOptions{ + Filename: path, + ContentType: "text/html", + }) + return + } + common.ServeContentByReader(ctx.Base, path, size, stream) +} + +func previewArtifactByReadSeeker(ctx *context_module.Context, path string, reader io.ReadSeeker) { + buf := make([]byte, typesniffer.SniffContentSize) + n, err := util.ReadAtMost(reader, buf) + if err != nil { + ctx.ServerError("ReadAtMost", err) + return + } + if n < 0 { + n = 0 + } + if _, err := reader.Seek(0, io.SeekStart); err != nil { + ctx.ServerError("Seek", err) + return + } + buf = buf[:n] + + st := typesniffer.DetectContentType(buf) + if !isPreviewableArtifactType(st) { + ctx.HTTPError(http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type") + return + } + setArtifactPreviewCSP(ctx, st) + + if st.GetMimeType() == "text/html" { + httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, nil, reader, &httplib.ServeHeaderOptions{ + Filename: path, + ContentType: "text/html", + }) + return + } + common.ServeContentByReadSeeker(ctx.Base, path, nil, reader) +} + +func ArtifactsPreviewView(ctx *context_module.Context) { + runIndex := getRunIndex(ctx) + artifactName := ctx.PathParam("artifact_name") + + run, artifacts, ok := getRunAndUploadedArtifacts(ctx, runIndex, artifactName) + if !ok { + return + } + + paths, err := listPreviewPaths(artifacts) + if err != nil { + ctx.ServerError("listPreviewPaths", err) + return + } + selectedPath := choosePreviewPath(paths, normalizeArtifactPreviewPath(ctx.Req.URL.Query().Get("path"))) + + previewFiles := make([]ArtifactPreviewFile, 0, len(paths)) + for _, path := range paths { + previewFiles = append(previewFiles, ArtifactPreviewFile{ + Path: path, + Selected: path == selectedPath, + }) + } + + runURL := run.Link() + artifactPath := url.PathEscape(artifactName) + previewURL := runURL + "/artifacts/" + artifactPath + "/preview" + + ctx.Data["Title"] = ctx.Tr("preview") + ctx.Data["PageIsActions"] = true + ctx.Data["RunURL"] = runURL + ctx.Data["ArtifactName"] = artifactName + ctx.Data["PreviewURL"] = previewURL + ctx.Data["PreviewRawURL"] = previewURL + "/raw" + ctx.Data["DownloadURL"] = runURL + "/artifacts/" + artifactPath + ctx.Data["SelectedPath"] = selectedPath + ctx.Data["PreviewFiles"] = previewFiles + + ctx.HTML(http.StatusOK, tplArtifactPreviewAction) +} + +func ArtifactsPreviewRawView(ctx *context_module.Context) { + runIndex := getRunIndex(ctx) + artifactName := ctx.PathParam("artifact_name") + + _, artifacts, ok := getRunAndUploadedArtifacts(ctx, runIndex, artifactName) + if !ok { + return + } + + paths, err := listPreviewPaths(artifacts) + if err != nil { + ctx.ServerError("listPreviewPaths", err) + return + } + selectedPath := choosePreviewPath(paths, normalizeArtifactPreviewPath(ctx.Req.URL.Query().Get("path"))) + if selectedPath == "" { + ctx.HTTPError(http.StatusNotFound, "artifact file not found") + return + } + + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + artifact := artifacts[0] + + buf, reader, err := openArtifactV4ZipReader(artifact) + if err != nil { + if !errors.Is(err, zip.ErrFormat) { + ctx.ServerError("openArtifactV4ZipReader", err) + return + } + + fallbackPath := artifactPreviewFallbackPath(artifact) + if selectedPath != fallbackPath { + ctx.HTTPError(http.StatusNotFound, "artifact file not found") + return + } + + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + ctx.ServerError("ActionsArtifacts.Open", err) + return + } + defer f.Close() + + previewArtifactByReadSeeker(ctx, selectedPath, f) + return + } + defer buf.Close() + + _, files := listArtifactV4ZipFiles(reader) + zf, ok := files[selectedPath] + if !ok { + ctx.HTTPError(http.StatusNotFound, "artifact file not found") + return + } + + r, err := zf.Open() + if err != nil { + ctx.ServerError("zip.File.Open", err) + return + } + defer r.Close() + + previewArtifactByReader(ctx, selectedPath, int64(zf.UncompressedSize64), r) + return + } + + legacyByPath := make(map[string]*actions_model.ActionArtifact, len(artifacts)) + for _, artifact := range artifacts { + path := artifactPreviewFallbackPath(artifact) + if _, ok := legacyByPath[path]; ok { + continue + } + legacyByPath[path] = artifact + } + + artifact, ok := legacyByPath[selectedPath] + if !ok { + ctx.HTTPError(http.StatusNotFound, "artifact file not found") + return + } + + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + ctx.ServerError("ActionsArtifacts.Open", err) + return + } + defer f.Close() + + if artifact.ContentEncoding == "gzip" { + r, err := gzip.NewReader(f) + if err != nil { + ctx.ServerError("gzip.NewReader", err) + return + } + defer r.Close() + + previewArtifactByReader(ctx, selectedPath, artifact.FileSize, r) + return + } + + previewArtifactByReadSeeker(ctx, selectedPath, f) +} + func ArtifactsDeleteView(ctx *context_module.Context) { run := getCurrentRunByPathParam(ctx) if ctx.Written() { @@ -692,36 +1058,14 @@ func ArtifactsDeleteView(ctx *context_module.Context) { } func ArtifactsDownloadView(ctx *context_module.Context) { - run := getCurrentRunByPathParam(ctx) - if ctx.Written() { - return - } - + runIndex := getRunIndex(ctx) artifactName := ctx.PathParam("artifact_name") - artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ - RunID: run.ID, - ArtifactName: artifactName, - }) - if err != nil { - ctx.ServerError("FindArtifacts", err) - return - } - if len(artifacts) == 0 { - ctx.HTTPError(http.StatusNotFound, "artifact not found") + _, artifacts, ok := getRunAndUploadedArtifacts(ctx, runIndex, artifactName) + if !ok { return } - // if artifacts status is not uploaded-confirmed, treat it as not found - for _, art := range artifacts { - if art.Status != actions_model.ArtifactStatusUploadConfirmed { - ctx.HTTPError(http.StatusNotFound, "artifact not found") - return - } - } - - // A v4 Artifact may only contain a single file - // Multiple files are uploaded as a single file archive - // All other cases fall back to the legacy v1–v3 zip handling below + ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index e3dcf27cc4..cbf4062b1f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1545,6 +1545,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/approve", reqRepoActionsWriter, actions.Approve) m.Post("/delete", reqRepoActionsWriter, actions.Delete) m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) + m.Get("/artifacts/{artifact_name}/preview", actions.ArtifactsPreviewView) + m.Get("/artifacts/{artifact_name}/preview/raw", actions.ArtifactsPreviewRawView) m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) m.Post("/rerun-failed", reqRepoActionsWriter, actions.RerunFailed) @@ -1748,6 +1750,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Any("/mail-preview/*", devtest.MailPreviewRender) m.Any("/{sub}", devtest.TmplCommon) m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView) + m.Get("/repo-action-view/runs/{run}/artifacts/{artifact_name}", devtest.MockActionsArtifactDownload) + m.Get("/repo-action-view/runs/{run}/artifacts/{artifact_name}/preview", devtest.MockActionsArtifactPreview) + m.Get("/repo-action-view/runs/{run}/artifacts/{artifact_name}/preview/raw", devtest.MockActionsArtifactPreviewRaw) m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView) m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) diff --git a/templates/repo/actions/artifact_preview.tmpl b/templates/repo/actions/artifact_preview.tmpl new file mode 100644 index 0000000000..6ac705a48e --- /dev/null +++ b/templates/repo/actions/artifact_preview.tmpl @@ -0,0 +1,79 @@ +{{template "base/head" .}} + +
+ {{template "repo/header" .}} + +
+ + +
+
+ +
+
+ {{if .SelectedPath}} + + {{else}} +
{{ctx.Locale.Tr "none"}}
+ {{end}} +
+
+
+
+ + + +{{template "base/footer" .}} diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 59b5c9cbf9..463e8278af 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -31,6 +31,7 @@ data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}" data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}" data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}" + data-locale-download-file="{{ctx.Locale.Tr "repo.download_file"}}" data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}" data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}" > diff --git a/tests/integration/actions_artifact_preview_test.go b/tests/integration/actions_artifact_preview_test.go new file mode 100644 index 0000000000..c127915f9c --- /dev/null +++ b/tests/integration/actions_artifact_preview_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "net/http" + "net/url" + "strings" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/storage" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func overwriteArtifactStorageContent(t *testing.T, artifactID int64, content []byte) { + t.Helper() + artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: artifactID}) + _, err := storage.ActionsArtifacts.Save(artifact.StoragePath, bytes.NewReader(content), int64(len(content))) + require.NoError(t, err) +} + +func TestActionsArtifactPreviewSingleFile(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + session := loginUser(t, "user2") + + req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview", repo.FullName()) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "abc.txt") + assert.Contains(t, resp.Body.String(), "/preview/raw?path=abc.txt") + + req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName()) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, strings.Repeat("A", 1024), resp.Body.String()) + assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") +} + +func TestActionsArtifactPreviewMultiFile(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + session := loginUser(t, "user2") + + req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview", repo.FullName()) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "abc.txt") + assert.Contains(t, resp.Body.String(), "xyz/def.txt") + + req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview/raw?path=%s", repo.FullName(), url.QueryEscape("xyz/def.txt")) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, strings.Repeat("C", 1024), resp.Body.String()) +} + +func TestActionsArtifactPreviewUnsupportedType(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + overwriteArtifactStorageContent(t, 1, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x00, 0x00, 0x0d}) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + session := loginUser(t, "user2") + req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName()) + session.MakeRequest(t, req, http.StatusUnsupportedMediaType) +} + +func TestActionsArtifactPreviewHTMLSandboxCSP(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + overwriteArtifactStorageContent(t, 1, []byte("

artifact

")) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + session := loginUser(t, "user2") + req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName()) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "sandbox") + assert.Contains(t, resp.Header().Get("Content-Type"), "text/html") +} + +func TestActionsArtifactDownloadViewUnchanged(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + session := loginUser(t, "user2") + req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download", repo.FullName()) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Header().Get("Content-Disposition"), "attachment; filename=artifact-download.zip") +} diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 3637763b90..6ba59eb123 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -121,13 +121,18 @@ async function deleteArtifact(name: string) {