mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 23:56:37 +09:00
feat(actions): Add artifact preview in Actions run view (#36754)
Closes https://github.com/go-gitea/gitea/issues/33579. Adds browser previews for Actions artifacts. Selecting an artifact opens its file browser; selecting a file renders it in the same tab. The ZIP download remains available separately. Previews require sign-in and read access to the run. Text, image and PDF files are supported; rendered HTML and JavaScript run in a sandboxed frame and are labeled as automatically generated. The frame loads files from a signed link that expires after an hour, because its requests carry no session cookie. `[actions] ARTIFACT_PREVIEW_MAX_SIZE` limits total previewable artifact size (`0` disables previews; `-1` removes the limit); individual files also follow `[ui] MAX_DISPLAY_FILE_SIZE`. <img width="1803" height="913" alt="image" src="https://github.com/user-attachments/assets/a38fd704-2244-44fa-9181-c695ecbe0276" /> Docs: https://gitea.com/gitea/docs/pulls/533 --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": `<html><head><link rel="stylesheet" href="style.css"></head><body>
|
||||
<a href="./style.css">link to style.css</a><br>
|
||||
<p>This line is red, next line is from JS:</p>
|
||||
<script>document.write('window origin: ' + window.origin)</script>
|
||||
</body></html>`,
|
||||
"report/style.css": "body {padding: 10px;} p {color: red;}",
|
||||
"demo.svg": `<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" x="10" y="10" rx="20" ry="20" fill="blue" />
|
||||
</svg>`,
|
||||
"demo.pdf": `%PDF-1.0
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/MediaBox[0 0 3 3]>>endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000052 00000 n
|
||||
0000000101 00000 n
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
149
|
||||
%EOF`,
|
||||
},
|
||||
mockActionsArtifactNameReallyLong: {
|
||||
"index.html": "<html><body>mock preview</body></html>",
|
||||
"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)))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "<head>"
|
||||
pos := bytes.Index(lowerBuf, []byte("<head"))
|
||||
if pos != -1 {
|
||||
pos1 := bytes.Index(lowerBuf[pos:], []byte(">"))
|
||||
if pos1 != -1 {
|
||||
pos += pos1 + 1
|
||||
}
|
||||
}
|
||||
|
||||
// try before "<body>"
|
||||
if pos == -1 {
|
||||
pos = bytes.Index(lowerBuf, []byte("<body"))
|
||||
if pos != -1 {
|
||||
addHeadTag = true
|
||||
}
|
||||
}
|
||||
|
||||
// try after "<html>"
|
||||
if pos == -1 {
|
||||
pos = bytes.Index(lowerBuf, []byte("<html"))
|
||||
if pos != -1 {
|
||||
pos1 := bytes.Index(lowerBuf[pos:], []byte(">"))
|
||||
if pos1 != -1 {
|
||||
pos += pos1 + 1
|
||||
addHeadTag = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
helperScript := htmlutil.HTMLFormat(`<script crossorigin src="%s"></script>`, public.AssetURI("web_src/js/external-render-helper.ts"))
|
||||
if addHeadTag {
|
||||
helperScript = "<head>" + helperScript + "</head>"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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: "<Head >any</head>",
|
||||
before: "<Head >",
|
||||
after: "any</head>",
|
||||
},
|
||||
{
|
||||
in: "any<Body >",
|
||||
before: "any<head>",
|
||||
after: "</head><Body >",
|
||||
},
|
||||
{
|
||||
in: "<Html >any</html>",
|
||||
before: "<Html ><head>",
|
||||
after: "</head>any</html>",
|
||||
},
|
||||
}
|
||||
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, "<script "))
|
||||
assert.True(t, strings.HasSuffix(out, "</script>"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,7 @@ func Routes() *web.Router {
|
||||
}
|
||||
|
||||
routes.Methods("GET,HEAD", "/robots.txt", append(mid, misc.RobotsTxt)...)
|
||||
routes.Get("/-/actions/artifacts/{artifact_id}/{expires}/{signature}/*", append(mid, actions.ArtifactsPreviewRawView)...) // no session, the sandboxed frame sends no cookie
|
||||
routes.Get("/ssh_info", misc.SSHInfo)
|
||||
routes.Get("/api/healthz", healthcheck.Check)
|
||||
|
||||
@@ -1593,6 +1594,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
|
||||
m.Post("/rerun-failed", reqRepoActionsWriter, actions.RerunFailed)
|
||||
})
|
||||
// signed-in only: previews render user-generated HTML under the instance domain, keep it from anonymous visitors and crawlers
|
||||
m.Group("/artifacts/{artifact_id}/preview", func() {
|
||||
m.Get("", actions.ArtifactsPreviewView)
|
||||
m.Get("/*", actions.ArtifactsPreviewView)
|
||||
}, reqSignIn)
|
||||
m.Group("/workflows/{workflow_name}", func() {
|
||||
m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge)
|
||||
})
|
||||
@@ -1796,6 +1802,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Any("/mail-preview-embed/*", devtest.MailPreviewEmbed)
|
||||
m.Any("/{sub}", devtest.TmplCommon)
|
||||
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
|
||||
m.Get("/repo-action-view/artifacts/{artifact_name}/preview", devtest.MockActionsArtifactPreview)
|
||||
m.Get("/repo-action-view/artifacts/{artifact_name}/preview/*", devtest.MockActionsArtifactPreview)
|
||||
m.Get("/repo-action-view/artifacts/{artifact_name}/raw/*", devtest.MockActionsArtifactPreviewRaw)
|
||||
m.Get("/repo-action-view/runs/{run}/attempts/{attempt}", devtest.MockActionsView)
|
||||
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
|
||||
m.Post("/repo-action-view/runs/{run}", web.Bind[*actions.ViewRequest](), devtest.MockActionsRunsJobs)
|
||||
|
||||
@@ -210,6 +210,9 @@ func (b *Base) SetHeaderContentSecurityPolicyGeneral() {
|
||||
|
||||
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
|
||||
reqCtx := reqctx.FromContext(req.Context())
|
||||
if reqCtx.Value(BaseContextKey) != nil {
|
||||
panic("Base context already exists in request context")
|
||||
}
|
||||
b := &Base{
|
||||
RequestContext: reqCtx,
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{{template "base/head" .}}
|
||||
{{$data := .ArtifactPreviewData}}
|
||||
<div class="page-content repository actions">
|
||||
<div class="ui fluid container">
|
||||
{{template "base/alert" .}}
|
||||
<div class="action-view-header">
|
||||
<a class="action-view-back" href="{{$data.RunURL}}">
|
||||
{{svg "octicon-arrow-left" 14}}
|
||||
{{if $data.RunAttempt}}
|
||||
{{ctx.Locale.Tr "actions.artifacts.back_to_run_attempt" $data.RunIndex $data.RunAttempt}}
|
||||
{{else}}
|
||||
{{ctx.Locale.Tr "actions.artifacts.back_to_run" $data.RunIndex}}
|
||||
{{end}}
|
||||
</a>
|
||||
<div class="action-info-summary">
|
||||
<h2 class="action-info-summary-title-text">{{ctx.Locale.Tr "preview"}}: {{$data.ArtifactName}}</h2>
|
||||
<a class="ui small compact button" href="{{$data.DownloadURL}}">
|
||||
{{svg "octicon-download"}}
|
||||
{{ctx.Locale.Tr "repo.download_file"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui fluid vertical menu">
|
||||
<div class="header item">{{ctx.Locale.Tr "files"}}</div>
|
||||
{{range $file := $data.PreviewFiles}}
|
||||
{{if $file.Link}}
|
||||
<a class="item flex-text-block artifact-preview-item {{if $file.Selected}}active{{end}}" style="--depth: {{$file.Depth}}" href="{{$file.Link}}" title="{{$file.Path}}">
|
||||
{{svg "octicon-file"}}
|
||||
<span class="gt-ellipsis">{{$file.Name}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="item flex-text-block artifact-preview-item" style="--depth: {{$file.Depth}}" title="{{$file.Path}}">
|
||||
{{svg "octicon-file-directory"}}
|
||||
<span class="gt-ellipsis">{{$file.Name}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="item">{{ctx.Locale.Tr "actions.artifacts.preview_no_files"}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-container-main">
|
||||
{{if $data.PreviewTooLarge}}
|
||||
<div class="ui warning message">{{ctx.Locale.Tr "actions.artifacts.preview_artifact_too_large"}}</div>
|
||||
{{end}}
|
||||
{{if $data.RequestedPathMissing}}
|
||||
<div class="ui warning message">{{ctx.Locale.Tr "actions.artifacts.preview_file_not_found"}}</div>
|
||||
{{end}}
|
||||
{{if $data.PreviewFilesTruncated}}
|
||||
<div class="ui info message">{{ctx.Locale.Tr "actions.artifacts.preview_file_list_truncated"}}</div>
|
||||
{{end}}
|
||||
{{if $data.PreviewContentURL}}
|
||||
<div class="ui fitted segment">
|
||||
<iframe class="external-render-iframe" data-src="{{$data.PreviewContentURL}}" data-global-init="initExternalRenderIframe" referrerpolicy="no-referrer"></iframe>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -45,6 +45,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-copy-output="{{ctx.Locale.Tr "copy_output"}}"
|
||||
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"}}"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
actions_web "gitea.dev/routers/web/repo/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setArtifactFile(t *testing.T, artifactID int64, artifactPath string, content []byte) {
|
||||
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)
|
||||
_, err = db.GetEngine(t.Context()).ID(artifactID).Cols("artifact_path", "file_size").Update(&actions_model.ActionArtifact{ArtifactPath: artifactPath, FileSize: int64(len(content))})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestActionsArtifactPreview(t *testing.T) {
|
||||
defer prepareTestEnvActionsArtifacts(t)()
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
t.Run("LegacyArtifact", func(t *testing.T) {
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview"), http.StatusSeeOther)
|
||||
assert.Contains(t, test.RedirectURL(resp), "/user/login")
|
||||
|
||||
resp = session.MakeRequest(t, NewRequest(t, "POST", "/user5/repo4/actions/runs/791"), http.StatusOK)
|
||||
previewLinks := map[string]string{}
|
||||
for _, artifact := range DecodeJSON(t, resp, &actions_web.ViewResponse{}).Artifacts {
|
||||
previewLinks[artifact.Name] = artifact.PreviewLink
|
||||
}
|
||||
assert.Equal(t, "/user5/repo4/actions/artifacts/19/preview", previewLinks["multi-file-download"])
|
||||
|
||||
resp = session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview/missing.txt"), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "The requested file is not present in this artifact.")
|
||||
|
||||
resp = session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/19/preview/xyz/def.txt"), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), `href="/user5/repo4/actions/runs/791"`)
|
||||
assert.Contains(t, resp.Body.String(), `href="/user5/repo4/actions/artifacts/19/preview/abc.txt"`)
|
||||
rawLink := NewHTMLParser(t, resp.Body).Find("iframe").AttrOr("data-src", "")
|
||||
assert.True(t, strings.HasPrefix(rawLink, "/-/actions/artifacts/19/"))
|
||||
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusOK)
|
||||
assert.Equal(t, strings.Repeat("C", 1024), resp.Body.String())
|
||||
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "*", resp.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Empty(t, resp.Header().Get("Set-Cookie"))
|
||||
|
||||
MakeRequest(t, NewRequest(t, "GET", strings.TrimSuffix(rawLink, "xyz/def.txt")+"missing.txt"), http.StatusNotFound)
|
||||
MakeRequest(t, NewRequest(t, "GET", strings.Replace(rawLink, "/19/", "/1/", 1)), http.StatusNotFound)
|
||||
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", rawLink).SetHeader("Sec-Fetch-Dest", "document"), http.StatusSeeOther)
|
||||
assert.Equal(t, "/user5/repo4/actions/artifacts/19/preview/xyz/def.txt", test.RedirectURL(resp))
|
||||
|
||||
defer timeutil.MockSet(time.Now().Add(2 * time.Hour))()
|
||||
MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("ContentTypes", func(t *testing.T) {
|
||||
var rawLink string
|
||||
for _, file := range []struct {
|
||||
path, content, contentType, csp string
|
||||
}{
|
||||
{"report.pdf", "%PDF-1.7\n", "application/pdf", "default-src 'none'; style-src 'unsafe-inline'"},
|
||||
{"image.png", "\x89PNG\r\n\x1a\n\x00\x00\x00\x0d", "image/png", "sandbox"},
|
||||
{"index.html", "<!DOCTYPE html><html>artifact</html>", "text/html; charset=utf-8", "sandbox"},
|
||||
} {
|
||||
setArtifactFile(t, 1, file.path, []byte(file.content))
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user5/repo4/actions/artifacts/1/preview/"+file.path), http.StatusOK)
|
||||
rawLink = NewHTMLParser(t, resp.Body).Find("iframe").AttrOr("data-src", "")
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusOK)
|
||||
assert.Equal(t, file.contentType, resp.Header().Get("Content-Type"))
|
||||
assert.Contains(t, resp.Header().Get("Content-Security-Policy"), file.csp) // CSP is from httplib package, we don't need to test the exact value here
|
||||
}
|
||||
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", rawLink), http.StatusOK)
|
||||
assert.Regexp(t, `^<!DOCTYPE html><html><head><script crossorigin src="[^"]+/external-render-helper[^"]*"></script></head>artifact</html>$`, resp.Body.String())
|
||||
})
|
||||
|
||||
t.Run("V4Zip", func(t *testing.T) {
|
||||
setArtifactFile(t, 22, "artifact-v4-download.zip", test.WriteZipArchive(map[string]string{"index.html": "<html>v4</html>", "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"`)
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
<div class="item" v-for="artifact in artifacts" :key="artifact.name">
|
||||
<template v-if="artifact.status !== 'expired'">
|
||||
<a
|
||||
class="tw-flex-1 tw-min-w-0 flex-text-block silenced" target="_blank"
|
||||
:href="buildArtifactLink(artifact.name)"
|
||||
class="tw-flex-1 tw-min-w-0 flex-text-block silenced"
|
||||
:href="artifact.previewLink"
|
||||
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
|
||||
data-tooltip-render="html"
|
||||
data-tooltip-placement="top-end"
|
||||
@@ -296,6 +297,9 @@ onBeforeUnmount(() => {
|
||||
<SvgIcon name="octicon-file" class="tw-text-text-light"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
</a>
|
||||
<a download class="silenced" :href="buildArtifactLink(artifact.name)" :data-tooltip-content="locale.downloadFile">
|
||||
<SvgIcon name="octicon-download"/>
|
||||
</a>
|
||||
<a v-if="run.canDeleteArtifact" class="silenced" @click="deleteArtifact(artifact.name)">
|
||||
<SvgIcon name="octicon-trash"/>
|
||||
</a>
|
||||
@@ -373,62 +377,6 @@ onBeforeUnmount(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view header */
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ================ */
|
||||
/* action view left */
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ function initRepositoryActionsView() {
|
||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
||||
downloadLogs: el.getAttribute('data-locale-download-logs'),
|
||||
downloadFile: el.getAttribute('data-locale-download-file'),
|
||||
copyOutput: el.getAttribute('data-locale-copy-output'),
|
||||
status: {
|
||||
unknown: el.getAttribute('data-locale-status-unknown'),
|
||||
|
||||
@@ -53,6 +53,10 @@ export function initExternalRenderIframe(iframe: HTMLIFrameElement) {
|
||||
if (cmd === 'resize') {
|
||||
iframe.style.height = `${e.data.iframeHeight}px`;
|
||||
} else if (cmd === 'open-link') {
|
||||
if (!navigator.userActivation.isActive) {
|
||||
console.error(`iframe attempted to open link without user activation: ${e.data.openLink}`);
|
||||
return;
|
||||
}
|
||||
navigateToIframeLink(e.data.openLink, e.data.anchorTarget);
|
||||
} else {
|
||||
throw new Error(`Unknown gitea iframe cmd: ${cmd}`);
|
||||
|
||||
@@ -88,4 +88,5 @@ export type ActionsArtifact = {
|
||||
size: number;
|
||||
status: ActionsArtifactStatus;
|
||||
expiresUnix: number;
|
||||
previewLink?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user