diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index aa1042e393e..836dbc96714 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -3064,6 +3064,8 @@ LEVEL = Info ;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded. ;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step. ;ARTIFACT_RETENTION_DAYS = 90 +;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit. +;ARTIFACT_PREVIEW_MAX_SIZE = 10485760 ;; Days to keep completed runs. Old runs and everything under them will be deleted after this period. 0 means keep forever. ;RUN_RETENTION_DAYS = 400 ;; Timeout to stop the task which have running status, but haven't been updated for a long time diff --git a/models/actions/artifact.go b/models/actions/artifact.go index e550227d034..476ffcf5277 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -210,8 +210,11 @@ func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact { }) } -// ActionArtifactMeta is the meta-data of an artifact +// ActionArtifactMeta is the metadata of an artifact type ActionArtifactMeta struct { + // The ID identifies the artifact group. The preview handler loads all files with the same run, attempt, and artifact name, then selects the requested file by path. + // It is the lowest row ID in the group query, legacy artifacts have one row per file. + ID int64 ArtifactName string FileSize int64 Status ArtifactStatus @@ -225,7 +228,7 @@ func ListUploadedArtifactsMetaByRunAttempt(ctx context.Context, repoID, runID, r return arts, db.GetEngine(ctx).Table("action_artifact"). Where("repo_id=? AND run_id=? AND run_attempt_id=? AND (status=? OR status=?)", repoID, runID, runAttemptID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). GroupBy("artifact_name"). - Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix"). + Select("min(id) as id, artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix"). Find(&arts) } diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index e2da9e85fa7..e269f5978d7 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -39,9 +39,9 @@ type ServeHeaderOptions struct { const ( // Disable JS execution on the same origin, since we serve the file from the same origin as Gitea server. - // This rule can be relaxed in the future as long as it is properly sandboxed. - // "style-src" is for SVG inline styles (from Display SVG files as images instead of text #14101) - serveHeaderCspDefault = "default-src 'none'; style-src 'unsafe-inline'; sandbox" + // 'unsafe-inline' is needed by inline script and SVG inline styles (from Display SVG files as images instead of text #14101), + // So we don't set any "*-src" rule here, just use sandbox. + serveHeaderCspDefault = "sandbox allow-scripts allow-modals allow-popups allow-downloads" // No sandbox attribute for PDF as it breaks rendering in at least Safari. // This should generally be safe as scripts inside PDF can not escape the PDF document. diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go index 9981a8e0e76..4ec77cfc63b 100644 --- a/modules/httplib/serve_test.go +++ b/modules/httplib/serve_test.go @@ -131,7 +131,8 @@ func TestServeSetHeaderContentRelated(t *testing.T) { } // make sure sandboxed - require.Contains(t, serveHeaderCspDefault, "; sandbox") + require.Contains(t, serveHeaderCspDefault, "sandbox") + require.NotContains(t, serveHeaderCspDefault, "allow-same-origin") } func TestServeSetHeaders(t *testing.T) { diff --git a/modules/setting/actions.go b/modules/setting/actions.go index 2605b297d67..6c5e6323adc 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -43,6 +43,8 @@ var ( // transaction at once per Gitea instance, to avoid a thundering herd when many // runners poll together. It is a per-process limit, not a cluster-wide one. MaxConcurrentTaskPicks int `ini:"MAX_CONCURRENT_TASK_PICKS"` + + ArtifactPreviewMaxSize int64 `ini:"ARTIFACT_PREVIEW_MAX_SIZE"` }{ Enabled: true, DefaultActionsURL: defaultActionsURLGitHub, @@ -51,6 +53,7 @@ var ( ScopedWorkflowDirs: []string{".gitea/scoped_workflows"}, MaxRerunAttempts: defaultMaxRerunAttempts, MaxConcurrentTaskPicks: defaultMaxConcurrentTaskPicks, + ArtifactPreviewMaxSize: 10 * 1024 * 1024, LogRetentionDays: defaultLogRetentionDays, ArtifactRetentionDays: defaultArtifactRetentionDays, RunRetentionDays: defaultRunRetentionDays, diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 28ae21e7eb5..7c376dbbb0e 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3928,6 +3928,12 @@ "actions.workflow.from_ref": "Use workflow from", "actions.workflow.has_workflow_dispatch": "This workflow has a workflow_dispatch event trigger.", "actions.workflow.has_no_workflow_dispatch": "Workflow '%s' has no workflow_dispatch event trigger.", + "actions.artifacts.preview_file_not_found": "The requested file is not present in this artifact.", + "actions.artifacts.preview_artifact_too_large": "This artifact is too large to preview. Please download it to view its contents.", + "actions.artifacts.preview_file_list_truncated": "This artifact contains many files, so only the first files are shown.", + "actions.artifacts.preview_no_files": "This artifact contains no files to preview.", + "actions.artifacts.back_to_run": "Back to action run #%d", + "actions.artifacts.back_to_run_attempt": "Back to action run #%d (attempt %d)", "actions.need_approval_desc": "Need approval to run workflows for fork pull request.", "actions.approve_all_success": "All workflow runs are approved successfully.", "actions.variables": "Variables", diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index dcdffd201fa..6a9f68a525b 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -5,8 +5,10 @@ package devtest import ( "fmt" + "maps" mathRand "math/rand/v2" "net/http" + "net/url" "slices" "strconv" "strings" @@ -23,6 +25,49 @@ import ( "gitea.dev/services/context" ) +const ( + mockActionsArtifactNameB = "artifact-b" + mockActionsArtifactNameHTMLReport = "artifact-html-report" + mockActionsArtifactNameReallyLong = "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong" +) + +var mockActionsArtifactFiles = map[string]map[string]string{ + mockActionsArtifactNameB: {"report.txt": "artifact-b report"}, + mockActionsArtifactNameHTMLReport: { + "report/index.html": ` +link to style.css
+

This line is red, next line is from JS:

+ +`, + "report/style.css": "body {padding: 10px;} p {color: red;}", + "demo.svg": ` + +`, + "demo.pdf": `%PDF-1.0 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000052 00000 n +0000000101 00000 n +trailer<> +startxref +149 +%EOF`, + }, + mockActionsArtifactNameReallyLong: { + "index.html": "mock preview", + "logs/output.txt": "mock logs", + }, +} + +func mockArtifactPreviewLink(artifactName string) string { + return setting.AppSubURL + "/devtest/repo-action-view/artifacts/" + url.PathEscape(artifactName) +} + type generateMockStepsLogOptions struct { mockCountFirst int mockCountGeneral int @@ -222,10 +267,11 @@ func MockActionsRunsJobs(ctx *context.Context) { ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-b", + Name: mockActionsArtifactNameB, Size: 1024 * 1024, Status: "completed", ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600), + PreviewLink: mockArtifactPreviewLink(mockActionsArtifactNameB) + "/preview", }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", @@ -234,10 +280,18 @@ func MockActionsRunsJobs(ctx *context.Context) { ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Name: mockActionsArtifactNameHTMLReport, + Size: 256 * 1024, + Status: "completed", + ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600), + PreviewLink: mockArtifactPreviewLink(mockActionsArtifactNameHTMLReport) + "/preview", + }) + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ + Name: mockActionsArtifactNameReallyLong, Size: 1024 * 1024, Status: "completed", ExpiresUnix: 0, + PreviewLink: mockArtifactPreviewLink(mockActionsArtifactNameReallyLong) + "/preview", }) jobLink := func(jobID int64) string { @@ -576,3 +630,18 @@ 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 MockActionsArtifactPreview(ctx *context.Context) { + artifactName := ctx.PathParam("artifact_name") + files := mockActionsArtifactFiles[artifactName] + runURL := setting.AppSubURL + "/devtest/repo-action-view/runs/10" + data := &actions.ArtifactPreviewTemplateData{RunURL: runURL, RunIndex: 10, ArtifactName: artifactName, DownloadURL: runURL + "/artifacts/" + url.PathEscape(artifactName)} + link := mockArtifactPreviewLink(artifactName) + actions.RenderArtifactPreview(ctx, data, slices.Sorted(maps.Keys(files)), ctx.PathParam("*"), link+"/preview/", link+"/raw/") +} + +func MockActionsArtifactPreviewRaw(ctx *context.Context) { + filePath := ctx.PathParam("*") + content := mockActionsArtifactFiles[ctx.PathParam("artifact_name")][filePath] + actions.ServeArtifactPreviewContent(ctx.Base, filePath, strings.NewReader(content), int64(len(content))) +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 56dc8955ab4..a4336727260 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -39,6 +39,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 13161f6a80c..47ceceddf68 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -287,6 +287,7 @@ type ArtifactsViewItem struct { Size int64 `json:"size"` Status string `json:"status"` ExpiresUnix int64 `json:"expiresUnix"` + PreviewLink string `json:"previewLink,omitempty"` } type ViewResponse struct { @@ -708,11 +709,14 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, } resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts)) for _, art := range arts { + allowPreview := ctx.IsSigned && isArtifactPreviewSizeAllowed(art.FileSize) + previewLink := fmt.Sprintf("%s/actions/artifacts/%d/preview", ctx.Repo.RepoLink, art.ID) resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{ Name: art.ArtifactName, Size: art.FileSize, Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), ExpiresUnix: int64(art.ExpiredUnix), + PreviewLink: util.Iif(allowPreview, previewLink, ""), }) } } diff --git a/routers/web/repo/actions/view_artifact.go b/routers/web/repo/actions/view_artifact.go new file mode 100644 index 00000000000..6364c142a3a --- /dev/null +++ b/routers/web/repo/actions/view_artifact.go @@ -0,0 +1,484 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/hmac" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/htmlutil" + "gitea.dev/modules/httplib" + "gitea.dev/modules/log" + "gitea.dev/modules/public" + "gitea.dev/modules/setting" + "gitea.dev/modules/storage" + "gitea.dev/modules/timeutil" + "gitea.dev/modules/typesniffer" + "gitea.dev/modules/util" + actions_service "gitea.dev/services/actions" + context_module "gitea.dev/services/context" + + "github.com/hashicorp/golang-lru/v2/expirable" +) + +type ArtifactPreviewFile struct { + Path string + Name string + Link string // empty for directories + Depth int + Selected bool +} + +type ArtifactPreviewTemplateData struct { + RunURL string + RunIndex int64 + RunAttempt int64 + ArtifactName string + DownloadURL string + PreviewContentURL string + RequestedPathMissing bool + PreviewTooLarge bool + PreviewFilesTruncated bool + PreviewFiles []ArtifactPreviewFile +} + +const artifactPreviewMaxFiles = 2000 + +type artifactPreviewList struct { + paths []string + truncated bool +} + +type artifactPreviewCacheKey struct { + artifactID int64 + updatedUnix timeutil.TimeStamp // a re-upload reuses the row, the ID alone could return a stale listing +} + +var artifactPreviewV4ZipListCache = expirable.NewLRU[artifactPreviewCacheKey, artifactPreviewList](128, nil, 10*time.Minute) + +type readAtBySeeker struct { + rs io.ReadSeeker + pos int64 +} + +func (r *readAtBySeeker) ReadAt(p []byte, off int64) (int, error) { + if off != r.pos { // some storages refetch on every seek, sequential reads must not + if _, err := r.rs.Seek(off, io.SeekStart); err != nil { + return 0, err + } + } + n, err := io.ReadFull(r.rs, p) + r.pos = off + int64(n) + if errors.Is(err, io.ErrUnexpectedEOF) { + return n, io.EOF + } + return n, err +} + +// RenderArtifactPreview renders the file browser, previewLink and rawLink are the bases for file paths +func RenderArtifactPreview(ctx *context_module.Context, data *ArtifactPreviewTemplateData, paths []string, requested, previewLink, rawLink string) { + if i, found := slices.BinarySearch(paths, requested); requested != "" && !found { + if data.PreviewFilesTruncated { + paths = slices.Insert(slices.Clone(paths), i, requested) // a capped listing cannot prove the file is missing, clone the cached slice + } else { + data.RequestedPathMissing = !data.PreviewTooLarge + requested = "" + } + } + data.PreviewFiles = buildArtifactPreviewFiles(paths, requested, previewLink) + if requested != "" { + data.PreviewContentURL = rawLink + util.PathEscapeSegments(requested) + } + ctx.Data["Title"] = ctx.Tr("preview") + ctx.Data["ArtifactPreviewData"] = data + ctx.HTML(http.StatusOK, tplArtifactPreviewAction) +} + +// loadUploadedArtifactsByID loads all rows of the artifact, legacy artifacts store one row per file +func loadUploadedArtifactsByID(ctx context.Context, artifactID int64) ([]*actions_model.ActionArtifact, error) { + art, exist, err := db.GetByID[actions_model.ActionArtifact](ctx, artifactID) + if err != nil { + return nil, err + } else if !exist { + return nil, util.ErrNotExist + } + artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, art.RunID, art.RunAttemptID, art.ArtifactName) + if err != nil { + return nil, err + } + if len(artifacts) == 0 || slices.ContainsFunc(artifacts, func(art *actions_model.ActionArtifact) bool { + return art.Status != actions_model.ArtifactStatusUploadConfirmed + }) { + return nil, util.ErrNotExist + } + return artifacts, nil +} + +func normalizeArtifactPreviewPath(path string) string { + path = util.PathJoinRelX(path) + if path == "." { + return "" + } + return path +} + +func artifactPreviewFallbackPath(artifact *actions_model.ActionArtifact) string { + return util.IfZero(normalizeArtifactPreviewPath(artifact.ArtifactPath), artifact.ArtifactName) +} + +func buildArtifactPreviewFiles(paths []string, selectedPath, previewLink string) []ArtifactPreviewFile { + previewFiles := make([]ArtifactPreviewFile, 0, len(paths)) + seenDirs := map[string]struct{}{} + for _, filePath := range paths { + parts := strings.Split(filePath, "/") + for i := range len(parts) - 1 { + dirPath := strings.Join(parts[:i+1], "/") + if _, ok := seenDirs[dirPath]; ok { + continue + } + seenDirs[dirPath] = struct{}{} + previewFiles = append(previewFiles, ArtifactPreviewFile{Path: dirPath, Name: parts[i], Depth: i}) + } + previewFiles = append(previewFiles, ArtifactPreviewFile{ + Path: filePath, + Name: path.Base(filePath), + Link: previewLink + util.PathEscapeSegments(filePath), + Depth: len(parts) - 1, + Selected: filePath == selectedPath, + }) + } + return previewFiles +} + +func newArtifactPreviewList(paths []string) artifactPreviewList { + slices.Sort(paths) + paths = slices.Compact(paths) + // clone so the cached list does not keep the full backing array alive + return artifactPreviewList{paths: slices.Clone(paths[:min(len(paths), artifactPreviewMaxFiles)]), truncated: len(paths) > artifactPreviewMaxFiles} +} + +func isArtifactPreviewSizeAllowed(size int64) bool { + maxSize := setting.Actions.ArtifactPreviewMaxSize + return maxSize < 0 || maxSize > 0 && size <= maxSize +} + +func artifactsTotalSize(artifacts []*actions_model.ActionArtifact) (size int64) { + for _, art := range artifacts { + size += art.FileSize + } + return size +} + +func openArtifactV4ZipReader(artifact *actions_model.ActionArtifact) (storage.Object, *zip.Reader, error) { + obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + return nil, nil, err + } + reader, err := zip.NewReader(&readAtBySeeker{rs: obj}, artifact.FileSize) + if err != nil { + _ = obj.Close() + return nil, nil, err + } + return obj, reader, nil +} + +func artifactV4ZipFilePath(file *zip.File) (string, bool) { + if file.Mode().IsDir() { + return "", false + } + path := normalizeArtifactPreviewPath(file.Name) + return path, path != "" +} + +func listPreviewForV4Artifact(artifact *actions_model.ActionArtifact) (artifactPreviewList, error) { + key := artifactPreviewCacheKey{artifactID: artifact.ID, updatedUnix: artifact.UpdatedUnix} + if list, ok := artifactPreviewV4ZipListCache.Get(key); ok { + return list, nil + } + + obj, reader, err := openArtifactV4ZipReader(artifact) + if errors.Is(err, zip.ErrFormat) { + list := artifactPreviewList{paths: []string{artifactPreviewFallbackPath(artifact)}} + artifactPreviewV4ZipListCache.Add(key, list) + return list, nil + } else if err != nil { + return artifactPreviewList{}, err + } + defer obj.Close() + + paths := make([]string, 0, len(reader.File)) + for _, file := range reader.File { + if path, ok := artifactV4ZipFilePath(file); ok { + paths = append(paths, path) + } + } + list := newArtifactPreviewList(paths) + artifactPreviewV4ZipListCache.Add(key, list) + return list, nil +} + +func listPreview(artifacts []*actions_model.ActionArtifact) (artifactPreviewList, error) { + if len(artifacts) == 1 && actions_service.IsArtifactV4(artifacts[0]) { + return listPreviewForV4Artifact(artifacts[0]) + } + paths := make([]string, len(artifacts)) + for i, artifact := range artifacts { + paths[i] = artifactPreviewFallbackPath(artifact) + } + return newArtifactPreviewList(paths), nil +} + +func artifactPreviewContentType(filename string, st typesniffer.SniffedType) string { + switch ext := strings.ToLower(path.Ext(filename)); ext { + case ".css", ".htm", ".html", ".js", ".mjs": + return public.DetectWellKnownMimeType(ext) + } + if st.IsTextPlain() { + return "text/plain; charset=utf-8" + } + return st.GetMimeType() +} + +func insertArtifactPreviewHelperScript(buf []byte) []byte { + addHeadTag := false + lowerBuf := bytes.ToLower(buf) + + // try after "" + pos := bytes.Index(lowerBuf, []byte("")) + if pos1 != -1 { + pos += pos1 + 1 + } + } + + // try before "" + if pos == -1 { + pos = bytes.Index(lowerBuf, []byte("" + if pos == -1 { + pos = bytes.Index(lowerBuf, []byte("")) + if pos1 != -1 { + pos += pos1 + 1 + addHeadTag = true + } + } + } + + helperScript := htmlutil.HTMLFormat(``, public.AssetURI("web_src/js/external-render-helper.ts")) + if addHeadTag { + helperScript = "" + helperScript + "" + } + + if pos == -1 { + // if no valid insertion point was found, prepend the script to the content + return append([]byte(helperScript), buf...) + } + ret := append(append([]byte(nil), buf[:pos]...), []byte(helperScript)...) + ret = append(ret, buf[pos:]...) + return ret +} + +// ServeArtifactPreviewContent serves a previewable file, size must be the exact content length +func ServeArtifactPreviewContent(ctx *context_module.Base, filePath string, reader io.Reader, size int64) { + if size < 0 || size > setting.UI.MaxDisplayFileSize { + ctx.HTTPError(http.StatusRequestEntityTooLarge, "file is too large to preview, please download the artifact instead") + return + } + reader = io.LimitReader(reader, size) // avoid reading more than the expected size + + headBuf, err := util.ReadWithLimit(reader, 8*1024) // will try to find HTML head in this buffer + if err != nil { + log.Error("artifact preview ReadWithLimit: %v", err) + ctx.HTTPError(http.StatusInternalServerError) + return + } + st := typesniffer.DetectContentType(headBuf) + if !st.IsText() && !st.IsImage() && !st.IsPDF() { + ctx.HTTPError(http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type") + return + } + + contentType := artifactPreviewContentType(filePath, st) + if strings.HasPrefix(contentType, "text/html") { + headBuf = insertArtifactPreviewHelperScript(headBuf) + } + httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{ + Filename: filePath, + ContentDisposition: httplib.ContentDispositionInline, + ContentType: contentType, + }) + ctx.Resp.Header().Set("Access-Control-Allow-Origin", "*") // module scripts and fetch() from the sandboxed opaque origin need CORS + _, _ = io.Copy(ctx.Resp, io.MultiReader(bytes.NewReader(headBuf), reader)) +} + +func ArtifactsPreviewView(ctx *context_module.Context) { + if setting.Actions.ArtifactPreviewMaxSize == 0 { + ctx.NotFound(nil) + return + } + artifacts, err := loadUploadedArtifactsByID(ctx, ctx.PathParamInt64("artifact_id")) + if err == nil && artifacts[0].RepoID != ctx.Repo.Repository.ID { + err = util.ErrNotExist + } + if err != nil { + ctx.ServerError("loadUploadedArtifactsByID", err) + return + } + artifact := artifacts[0] + run, err := actions_model.GetRunByRepoAndID(ctx, artifact.RepoID, artifact.RunID) + if err != nil { + ctx.ServerError("GetRunByRepoAndID", err) + return + } + run.Repo = ctx.Repo.Repository + + data := &ArtifactPreviewTemplateData{ + RunURL: run.Link(), + RunIndex: run.Index, + ArtifactName: artifact.ArtifactName, + DownloadURL: run.Link() + "/artifacts/" + url.PathEscape(artifact.ArtifactName), + PreviewTooLarge: !isArtifactPreviewSizeAllowed(artifactsTotalSize(artifacts)), + } + if artifact.RunAttemptID > 0 { + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, artifact.RepoID, artifact.RunAttemptID) + if err != nil { + ctx.ServerError("GetRunAttemptByRepoAndID", err) + return + } + data.RunAttempt = attempt.Attempt + data.RunURL += "/attempts/" + strconv.FormatInt(attempt.Attempt, 10) + data.DownloadURL += "?attempt=" + strconv.FormatInt(attempt.Attempt, 10) + } + + var list artifactPreviewList + if !data.PreviewTooLarge { + if list, err = listPreview(artifacts); err != nil { + ctx.ServerError("listPreview", err) + return + } + } + data.PreviewFilesTruncated = list.truncated + previewLink := fmt.Sprintf("%s/actions/artifacts/%d/preview/", ctx.Repo.RepoLink, artifact.ID) + RenderArtifactPreview(ctx, data, list.paths, normalizeArtifactPreviewPath(ctx.PathParam("*")), previewLink, artifactPreviewRawLink(artifact.ID)) +} + +func artifactPreviewSignature(artifactID, expires int64) string { + return base64.RawURLEncoding.EncodeToString(actions_module.BuildSignature("artifact-preview", strconv.FormatInt(artifactID, 10), strconv.FormatInt(expires, 10))) +} + +// artifactPreviewRawLink is signed because the sandboxed preview frame's requests carry no session cookie +func artifactPreviewRawLink(artifactID int64) string { + expires := int64(timeutil.TimeStampNow().AddDuration(time.Hour)) + return fmt.Sprintf("%s/-/actions/artifacts/%d/%d/%s/", setting.AppSubURL, artifactID, expires, artifactPreviewSignature(artifactID, expires)) +} + +func ArtifactsPreviewRawView(resp http.ResponseWriter, req *http.Request) { + ctx := context_module.NewBaseContext(resp, req) + artifactID, expires := ctx.PathParamInt64("artifact_id"), ctx.PathParamInt64("expires") + if !setting.Actions.Enabled || setting.Actions.ArtifactPreviewMaxSize == 0 || expires < int64(timeutil.TimeStampNow()) || + !hmac.Equal([]byte(ctx.PathParam("signature")), []byte(artifactPreviewSignature(artifactID, expires))) { + ctx.HTTPError(http.StatusNotFound) + return + } + if err := serveArtifactPreviewRaw(ctx, artifactID, normalizeArtifactPreviewPath(ctx.PathParam("*"))); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.HTTPError(http.StatusNotFound) + } else { + log.Error("serveArtifactPreviewRaw: %v", err) + ctx.HTTPError(http.StatusInternalServerError) + } + } +} + +func serveArtifactPreviewRaw(ctx *context_module.Base, artifactID int64, filePath string) error { + artifacts, err := loadUploadedArtifactsByID(ctx, artifactID) + if err != nil { + return err + } + // only the preview page shows that the content is generated, so top-level navigations go there + if ctx.Req.Header.Get("Sec-Fetch-Dest") == "document" { + repo, err := repo_model.GetRepositoryByID(ctx, artifacts[0].RepoID) + if err != nil { + return err + } + ctx.Redirect(fmt.Sprintf("%s/actions/artifacts/%d/preview/%s", repo.Link(), artifactID, util.PathEscapeSegments(filePath))) + return nil + } + if !isArtifactPreviewSizeAllowed(artifactsTotalSize(artifacts)) { + ctx.HTTPError(http.StatusRequestEntityTooLarge, "artifact is too large to preview, please download it instead") + return nil + } + + if len(artifacts) == 1 && actions_service.IsArtifactV4(artifacts[0]) { + obj, reader, err := openArtifactV4ZipReader(artifacts[0]) + if err == nil { + defer obj.Close() + idx := slices.IndexFunc(reader.File, func(file *zip.File) bool { + path, ok := artifactV4ZipFilePath(file) + return ok && path == filePath + }) + if idx == -1 { + return util.ErrNotExist + } + zipFile := reader.File[idx] + entryReader, err := zipFile.Open() + if err != nil { + return err + } + defer entryReader.Close() + ServeArtifactPreviewContent(ctx, filePath, entryReader, int64(zipFile.UncompressedSize64)) + return nil + } else if !errors.Is(err, zip.ErrFormat) { + return err + } + } + + idx := slices.IndexFunc(artifacts, func(art *actions_model.ActionArtifact) bool { return artifactPreviewFallbackPath(art) == filePath }) + if idx == -1 { + return util.ErrNotExist + } + artifact := artifacts[idx] + obj, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + return err + } + defer obj.Close() + var reader io.Reader = obj + if artifact.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip { + gzipReader, err := gzip.NewReader(obj) + if err != nil { + return err + } + defer gzipReader.Close() + reader = gzipReader + } + ServeArtifactPreviewContent(ctx, filePath, reader, artifact.FileSize) + return nil +} diff --git a/routers/web/repo/actions/view_artifact_test.go b/routers/web/repo/actions/view_artifact_test.go new file mode 100644 index 00000000000..00019ff06b1 --- /dev/null +++ b/routers/web/repo/actions/view_artifact_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "strconv" + "strings" + "testing" + + "gitea.dev/modules/setting" + "gitea.dev/modules/test" + "gitea.dev/modules/typesniffer" + + "github.com/stretchr/testify/assert" +) + +func TestNewArtifactPreviewList(t *testing.T) { + paths := []string{"b.txt", "a.txt", "b.txt"} + assert.Equal(t, artifactPreviewList{paths: []string{"a.txt", "b.txt"}}, newArtifactPreviewList(paths)) + + paths = make([]string, artifactPreviewMaxFiles+10) + for i := range paths { + paths[i] = "file-" + strconv.Itoa(i) + } + list := newArtifactPreviewList(paths) + assert.True(t, list.truncated) + assert.Len(t, list.paths, artifactPreviewMaxFiles) + paths[0] = "changed" + assert.Equal(t, "file-0", list.paths[0]) +} + +func TestBuildArtifactPreviewFiles(t *testing.T) { + files := buildArtifactPreviewFiles([]string{"README.md", "report/assets/chart.svg", "report/index.html"}, "report/index.html", "/preview/") + assert.Equal(t, []ArtifactPreviewFile{ + {Path: "README.md", Name: "README.md", Link: "/preview/README.md"}, + {Path: "report", Name: "report"}, + {Path: "report/assets", Name: "assets", Depth: 1}, + {Path: "report/assets/chart.svg", Name: "chart.svg", Link: "/preview/report/assets/chart.svg", Depth: 2}, + {Path: "report/index.html", Name: "index.html", Link: "/preview/report/index.html", Depth: 1, Selected: true}, + }, files) +} + +func TestArtifactPreviewContentType(t *testing.T) { + sniffedText := typesniffer.FromContentType("text/plain; charset=utf-8") + assert.Equal(t, "text/html; charset=utf-8", artifactPreviewContentType("index.HTM", sniffedText)) + assert.Equal(t, "text/css; charset=utf-8", artifactPreviewContentType("style.css", sniffedText)) + assert.Equal(t, "text/javascript; charset=utf-8", artifactPreviewContentType("script.mjs", sniffedText)) + assert.Equal(t, "text/plain; charset=utf-8", artifactPreviewContentType("output.txt", sniffedText)) + assert.Equal(t, "image/png", artifactPreviewContentType("image.txt", typesniffer.FromContentType("image/png"))) +} + +func TestIsArtifactPreviewSizeAllowed(t *testing.T) { + defer test.MockVariableValue(&setting.Actions.ArtifactPreviewMaxSize, int64(-1))() + assert.True(t, isArtifactPreviewSizeAllowed(1<<40)) + + setting.Actions.ArtifactPreviewMaxSize = 0 + assert.False(t, isArtifactPreviewSizeAllowed(0)) + + setting.Actions.ArtifactPreviewMaxSize = 10 + assert.True(t, isArtifactPreviewSizeAllowed(10)) + assert.False(t, isArtifactPreviewSizeAllowed(11)) +} + +func TestInsertArtifactPreviewHelperScript(t *testing.T) { + cases := []struct { + in string + before, after string + }{ + { + in: "any", + before: "", + after: "any", + }, + { + in: "any", + before: "", + after: "any", + }, + { + in: "any", + before: "any", + after: "", + }, + { + in: "any", + before: "", + after: "any", + }, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + out := string(insertArtifactPreviewHelperScript([]byte(c.in))) + out, ok := strings.CutPrefix(out, c.before) + assert.True(t, ok, "expect prefix %q for input %q", c.before, c.in) + out, ok = strings.CutSuffix(out, c.after) + assert.True(t, ok, "expect suffix %q for input %q", c.after, c.in) + assert.True(t, strings.HasPrefix(out, "artifact$`, resp.Body.String()) + }) + + t.Run("V4Zip", func(t *testing.T) { + setArtifactFile(t, 22, "artifact-v4-download.zip", test.WriteZipArchive(map[string]string{"index.html": "v4", "css/style.css": "body{}"}).Bytes()) + + resp := session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/22/preview/index.html"), http.StatusOK) + assert.Contains(t, resp.Body.String(), `href="/user5/repo4/actions/artifacts/22/preview/css/style.css"`) + rawLink := NewHTMLParser(t, resp.Body).Find("iframe").AttrOr("data-src", "") + + resp = MakeRequest(t, NewRequest(t, "GET", strings.TrimSuffix(rawLink, "index.html")+"css/style.css"), http.StatusOK) + assert.Equal(t, "body{}", resp.Body.String()) + assert.Equal(t, "text/css; charset=utf-8", resp.Header().Get("Content-Type")) + }) + + t.Run("Limits", func(t *testing.T) { + resp := session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview/abc.txt"), http.StatusOK) + rawLink := NewHTMLParser(t, resp.Body).Find("iframe").AttrOr("data-src", "") + + restore := test.MockVariableValue(&setting.UI.MaxDisplayFileSize, 16) + MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusRequestEntityTooLarge) + restore() + + defer test.MockVariableValue(&setting.Actions.ArtifactPreviewMaxSize, 1)() + resp = session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview/abc.txt"), http.StatusOK) + assert.Contains(t, resp.Body.String(), "This artifact is too large to preview.") + assert.NotContains(t, resp.Body.String(), "The requested file is not present") + MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusRequestEntityTooLarge) + + setting.Actions.ArtifactPreviewMaxSize = 0 + session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview"), http.StatusNotFound) + MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusNotFound) + resp = session.MakeRequest(t, NewRequest(t, "POST", "/user5/repo4/actions/runs/791"), http.StatusOK) + for _, artifact := range DecodeJSON(t, resp, &actions_web.ViewResponse{}).Artifacts { + assert.Empty(t, artifact.PreviewLink) + } + }) + + t.Run("Attempt", func(t *testing.T) { + attempt := &actions_model.ActionRunAttempt{RepoID: 4, RunID: 791, Attempt: 2, TriggerUserID: 1, Status: actions_model.StatusSuccess} + require.NoError(t, db.Insert(t.Context(), attempt)) + _, err := db.GetEngine(t.Context()).In("id", 19, 20).Cols("run_attempt_id").Update(&actions_model.ActionArtifact{RunAttemptID: attempt.ID}) + require.NoError(t, err) + + resp := session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview"), http.StatusOK) + assert.Contains(t, resp.Body.String(), `href="/user5/repo4/actions/runs/791/attempts/2"`) + assert.Contains(t, resp.Body.String(), `href="/user5/repo4/actions/runs/791/artifacts/multi-file-download?attempt=2"`) + }) +} diff --git a/tests/integration/download_test.go b/tests/integration/download_test.go index 1ec2a67c7a9..e97a5d76557 100644 --- a/tests/integration/download_test.go +++ b/tests/integration/download_test.go @@ -37,7 +37,7 @@ func TestDownloadRepoContent(t *testing.T) { t.Run("SVGUsesSecureHeaders", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo2/raw/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b") resp := session.MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "default-src 'none'; style-src 'unsafe-inline'; sandbox", resp.Header().Get("Content-Security-Policy")) + assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "sandbox") assert.Equal(t, "image/svg+xml", resp.Header().Get("Content-Type")) assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) }) @@ -51,7 +51,7 @@ func TestDownloadRepoContent(t *testing.T) { t.Run("MediaSVGUsesSecureHeaders", func(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo2/media/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b") resp := session.MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "default-src 'none'; style-src 'unsafe-inline'; sandbox", resp.Header().Get("Content-Security-Policy")) + assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "sandbox") assert.Equal(t, "image/svg+xml", resp.Header().Get("Content-Type")) assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) }) diff --git a/web_src/css/actions.css b/web_src/css/actions.css index 23750b8f1b2..deb5871f450 100644 --- a/web_src/css/actions.css +++ b/web_src/css/actions.css @@ -94,3 +94,61 @@ order: 2; margin-left: auto; } + +.action-view-header { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + min-height: 50px; /* reserve the back link and title height so the body does not shift when the run data arrives */ +} + +.action-view-back { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 4px; + font-size: 13px; + color: var(--color-text-light-1); + text-decoration: none; +} + +.action-view-back:hover { + color: var(--color-text); + text-decoration: none; +} + +.action-info-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.action-info-summary-title { + display: flex; + align-items: center; + gap: 0.5em; +} + +.action-info-summary-title-text { + font-size: 20px; + margin: 0; + overflow-wrap: anywhere; +} + +.action-info-summary-title-index { + font-size: 20px; + color: var(--color-text-light-2); + flex: 1; +} + +.action-info-summary .ui.button { + margin: 0; + white-space: nowrap; +} + +.ui.vertical.menu .artifact-preview-item { + padding-left: calc(1em + var(--depth, 0) * 1.25em); +} diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index a6893c57498..05fba86cd02 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -35,6 +35,7 @@ type RepoActionViewLocale = ActionRunSummaryViewLocale & ActionRunJobViewLocale artifactExpiresAt: string, artifactExpiredAt: string, confirmDeleteArtifact: string, + downloadFile: string, workflowFile: string, workflowFileNoPermission: string, runDetails: string, @@ -287,8 +288,8 @@ onBeforeUnmount(() => {