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:
bircni
2026-09-26 09:42:36 +02:00
committed by GitHub
parent 857d3d3df3
commit 5a56e118e4
22 changed files with 971 additions and 68 deletions
+2
View File
@@ -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
+5 -2
View File
@@ -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)
}
+3 -3
View File
@@ -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.
+2 -1
View File
@@ -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) {
+3
View File
@@ -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,
+6
View File
@@ -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",
+71 -2
View File
@@ -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)))
}
+1
View File
@@ -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 {
+4
View File
@@ -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, ""),
})
}
}
+484
View File
@@ -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>"))
})
}
}
+9
View File
@@ -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)
+3
View File
@@ -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"`)
})
}
+2 -2
View File
@@ -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"))
})
+58
View File
@@ -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);
}
+6 -58
View File
@@ -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 */
+1
View File
@@ -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'),
+4
View File
@@ -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}`);
+1
View File
@@ -88,4 +88,5 @@ export type ActionsArtifact = {
size: number;
status: ActionsArtifactStatus;
expiresUnix: number;
previewLink?: string;
};