0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-11 11:25:42 +02:00

Add Actions artifact preview with devtest coverage

This commit is contained in:
Nicolas 2026-02-25 20:21:45 +01:00
parent 4747dd68bd
commit a5de7842c2
9 changed files with 776 additions and 30 deletions

View File

@ -4,21 +4,132 @@
package devtest
import (
"archive/zip"
"fmt"
"html/template"
"io"
mathRand "math/rand/v2"
"net/http"
"net/url"
"path"
"slices"
"strconv"
"strings"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
"code.gitea.io/gitea/routers/web/repo/actions"
"code.gitea.io/gitea/services/context"
)
type mockArtifactFile struct {
Path string
Content string
}
type mockArtifactPreviewTemplateData struct {
ArtifactName string
Files []mockArtifactPreviewTemplateFile
PreviewURL string
PreviewRaw string
DownloadURL string
SelectedPath string
}
type mockArtifactPreviewTemplateFile struct {
Path string
Selected bool
}
var mockActionsArtifactFiles = map[string][]mockArtifactFile{
"artifact-b": {
{
Path: "report.txt",
Content: "artifact-b report",
},
},
"artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": {
{
Path: "index.html",
Content: `<!doctype html>
<html>
<body>
<h1>Mock Artifact Preview</h1>
<p>artifact-really-loooooong</p>
</body>
</html>`,
},
{
Path: "logs/output.txt",
Content: "mock logs",
},
},
}
var mockArtifactPreviewTemplate = template.Must(template.New("mock-artifact-preview").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Artifact Preview</title>
<style>
body { font-family: sans-serif; margin: 16px; }
.layout { display: grid; grid-template-columns: 260px 1fr; gap: 16px; }
.files { border: 1px solid #ddd; border-radius: 6px; padding: 8px; }
.files a { display: block; padding: 8px; text-decoration: none; color: inherit; border-radius: 4px; }
.files a.selected { background: #f0f6ff; }
iframe { width: 100%; min-height: 70vh; border: 1px solid #ddd; border-radius: 6px; }
</style>
</head>
<body>
<h2>Preview: {{.ArtifactName}}</h2>
<p><a href="{{.PreviewURL}}">Reload</a> | <a href="{{.DownloadURL}}">Download ZIP</a></p>
<div class="layout">
<div class="files">
{{range .Files}}
<a class="{{if .Selected}}selected{{end}}" href="{{$.PreviewURL}}?path={{.Path | urlquery}}">{{.Path}}</a>
{{end}}
</div>
<div>
{{if .SelectedPath}}
<iframe src="{{.PreviewRaw}}?path={{.SelectedPath | urlquery}}" referrerpolicy="same-origin"></iframe>
{{else}}
<p>No files</p>
{{end}}
</div>
</div>
</body>
</html>`))
func normalizeMockArtifactPath(path string) string {
path = util.PathJoinRelX(path)
if path == "." {
return ""
}
return path
}
func getMockArtifactFiles(name string) ([]mockArtifactFile, bool) {
files, ok := mockActionsArtifactFiles[name]
return files, ok
}
func chooseMockArtifactPath(files []mockArtifactFile, requestedPath string) string {
if len(files) == 0 {
return ""
}
for _, file := range files {
if file.Path == requestedPath {
return requestedPath
}
}
return files[0].Path
}
type generateMockStepsLogOptions struct {
mockCountFirst int
mockCountGeneral int
@ -227,3 +338,101 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
}
}
func MockActionsArtifactDownload(ctx *context.Context) {
artifactName := ctx.PathParam("artifact_name")
files, ok := getMockArtifactFiles(artifactName)
if !ok {
ctx.NotFound(nil)
return
}
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
writer := zip.NewWriter(ctx.Resp)
defer writer.Close()
for _, file := range files {
w, err := writer.Create(file.Path)
if err != nil {
ctx.ServerError("writer.Create", err)
return
}
if _, err := io.WriteString(w, file.Content); err != nil {
ctx.ServerError("io.WriteString", err)
return
}
}
}
func MockActionsArtifactPreview(ctx *context.Context) {
runID := ctx.PathParamInt64("run")
artifactName := ctx.PathParam("artifact_name")
files, ok := getMockArtifactFiles(artifactName)
if !ok {
ctx.NotFound(nil)
return
}
selectedPath := chooseMockArtifactPath(files, normalizeMockArtifactPath(ctx.Req.URL.Query().Get("path")))
templateFiles := make([]mockArtifactPreviewTemplateFile, 0, len(files))
for _, file := range files {
templateFiles = append(templateFiles, mockArtifactPreviewTemplateFile{
Path: file.Path,
Selected: file.Path == selectedPath,
})
}
previewURL := fmt.Sprintf("%s/devtest/repo-action-view/runs/%d/artifacts/%s/preview", setting.AppSubURL, runID, url.PathEscape(artifactName))
previewRawURL := previewURL + "/raw"
downloadURL := fmt.Sprintf("%s/devtest/repo-action-view/runs/%d/artifacts/%s", setting.AppSubURL, runID, url.PathEscape(artifactName))
data := mockArtifactPreviewTemplateData{
ArtifactName: artifactName,
Files: templateFiles,
PreviewURL: previewURL,
PreviewRaw: previewRawURL,
DownloadURL: downloadURL,
SelectedPath: selectedPath,
}
ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := mockArtifactPreviewTemplate.Execute(ctx.Resp, data); err != nil {
ctx.ServerError("mockArtifactPreviewTemplate.Execute", err)
return
}
}
func MockActionsArtifactPreviewRaw(ctx *context.Context) {
artifactName := ctx.PathParam("artifact_name")
files, ok := getMockArtifactFiles(artifactName)
if !ok {
ctx.NotFound(nil)
return
}
selectedPath := chooseMockArtifactPath(files, normalizeMockArtifactPath(ctx.Req.URL.Query().Get("path")))
if selectedPath == "" {
ctx.NotFound(nil)
return
}
var selectedFile *mockArtifactFile
for i := range files {
if files[i].Path == selectedPath {
selectedFile = &files[i]
break
}
}
if selectedFile == nil {
ctx.NotFound(nil)
return
}
if path.Ext(selectedFile.Path) == ".html" {
ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
httplib.ServeContentByReader(ctx.Req, ctx.Resp, int64(len(selectedFile.Content)), strings.NewReader(selectedFile.Content), &httplib.ServeHeaderOptions{
Filename: selectedFile.Path,
ContentType: "text/html",
})
return
}
common.ServeContentByReader(ctx.Base, selectedFile.Path, int64(len(selectedFile.Content)), strings.NewReader(selectedFile.Content))
}

View File

@ -36,6 +36,7 @@ const (
tplListActions templates.TplName = "repo/actions/list"
tplDispatchInputsActions templates.TplName = "repo/actions/workflow_dispatch_inputs"
tplViewActions templates.TplName = "repo/actions/view"
tplArtifactPreviewAction templates.TplName = "repo/actions/artifact_preview"
)
type WorkflowInfo struct {

View File

@ -5,6 +5,7 @@ package actions
import (
"archive/zip"
"bytes"
"compress/gzip"
"context"
"errors"
@ -13,6 +14,7 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strconv"
"time"
@ -26,10 +28,13 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/util/filebuffer"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/common"
actions_service "code.gitea.io/gitea/services/actions"
@ -124,6 +129,11 @@ type ArtifactsViewItem struct {
Status string `json:"status"`
}
type ArtifactPreviewFile struct {
Path string
Selected bool
}
type ViewResponse struct {
Artifacts []*ArtifactsViewItem `json:"artifacts"`
@ -678,6 +688,362 @@ func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.A
return run, jobs
}
func getRunAndUploadedArtifacts(ctx *context_module.Context, runIndex int64, artifactName string) (*actions_model.ActionRun, []*actions_model.ActionArtifact, bool) {
run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound, err.Error())
return nil, nil, false
}
ctx.ServerError("GetRunByIndex", err)
return nil, nil, false
}
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: run.ID,
ArtifactName: artifactName,
})
if err != nil {
ctx.ServerError("FindArtifacts", err)
return nil, nil, false
}
if len(artifacts) == 0 {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
return nil, nil, false
}
for _, art := range artifacts {
if art.Status != actions_model.ArtifactStatusUploadConfirmed {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
return nil, nil, false
}
}
run.Repo = ctx.Repo.Repository
return run, artifacts, true
}
func normalizeArtifactPreviewPath(path string) string {
path = util.PathJoinRelX(path)
if path == "." {
return ""
}
return path
}
func artifactPreviewFallbackPath(artifact *actions_model.ActionArtifact) string {
path := normalizeArtifactPreviewPath(artifact.ArtifactPath)
if path != "" {
return path
}
return artifact.ArtifactName
}
func choosePreviewPath(paths []string, requested string) string {
if len(paths) == 0 {
return ""
}
if util.SliceContainsString(paths, requested) {
return requested
}
return paths[0]
}
func listPreviewPathsForLegacyArtifacts(artifacts []*actions_model.ActionArtifact) []string {
paths := make([]string, 0, len(artifacts))
seen := make(map[string]struct{}, len(artifacts))
for _, artifact := range artifacts {
path := artifactPreviewFallbackPath(artifact)
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func openArtifactV4ZipReader(artifact *actions_model.ActionArtifact) (*filebuffer.FileBackedBuffer, *zip.Reader, error) {
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
return nil, nil, err
}
defer f.Close()
buf := filebuffer.New(int(setting.UI.MaxDisplayFileSize), "")
if _, err := io.Copy(buf, f); err != nil {
_ = buf.Close()
return nil, nil, err
}
reader, err := zip.NewReader(buf, buf.Size())
if err != nil {
_ = buf.Close()
return nil, nil, err
}
return buf, reader, nil
}
func listArtifactV4ZipFiles(reader *zip.Reader) ([]string, map[string]*zip.File) {
paths := make([]string, 0, len(reader.File))
files := make(map[string]*zip.File, len(reader.File))
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
path := normalizeArtifactPreviewPath(file.Name)
if path == "" {
continue
}
if _, ok := files[path]; ok {
continue
}
files[path] = file
paths = append(paths, path)
}
sort.Strings(paths)
return paths, files
}
func listPreviewPathsForV4Artifact(artifact *actions_model.ActionArtifact) ([]string, error) {
buf, reader, err := openArtifactV4ZipReader(artifact)
if err != nil {
if errors.Is(err, zip.ErrFormat) {
return []string{artifactPreviewFallbackPath(artifact)}, nil
}
return nil, err
}
defer buf.Close()
paths, _ := listArtifactV4ZipFiles(reader)
return paths, nil
}
func listPreviewPaths(artifacts []*actions_model.ActionArtifact) ([]string, error) {
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
return listPreviewPathsForV4Artifact(artifacts[0])
}
return listPreviewPathsForLegacyArtifacts(artifacts), nil
}
func isPreviewableArtifactType(st typesniffer.SniffedType) bool {
return st.IsText() || st.IsPDF()
}
func setArtifactPreviewCSP(ctx *context_module.Context, st typesniffer.SniffedType) {
if st.GetMimeType() == "text/html" {
ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
}
}
func previewArtifactByReader(ctx *context_module.Context, path string, size int64, reader io.Reader) {
buf := make([]byte, typesniffer.SniffContentSize)
n, err := util.ReadAtMost(reader, buf)
if err != nil {
ctx.ServerError("ReadAtMost", err)
return
}
if n < 0 {
n = 0
}
buf = buf[:n]
st := typesniffer.DetectContentType(buf)
if !isPreviewableArtifactType(st) {
ctx.HTTPError(http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type")
return
}
setArtifactPreviewCSP(ctx, st)
stream := io.MultiReader(bytes.NewReader(buf), reader)
if st.GetMimeType() == "text/html" {
httplib.ServeContentByReader(ctx.Req, ctx.Resp, size, stream, &httplib.ServeHeaderOptions{
Filename: path,
ContentType: "text/html",
})
return
}
common.ServeContentByReader(ctx.Base, path, size, stream)
}
func previewArtifactByReadSeeker(ctx *context_module.Context, path string, reader io.ReadSeeker) {
buf := make([]byte, typesniffer.SniffContentSize)
n, err := util.ReadAtMost(reader, buf)
if err != nil {
ctx.ServerError("ReadAtMost", err)
return
}
if n < 0 {
n = 0
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
ctx.ServerError("Seek", err)
return
}
buf = buf[:n]
st := typesniffer.DetectContentType(buf)
if !isPreviewableArtifactType(st) {
ctx.HTTPError(http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type")
return
}
setArtifactPreviewCSP(ctx, st)
if st.GetMimeType() == "text/html" {
httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, nil, reader, &httplib.ServeHeaderOptions{
Filename: path,
ContentType: "text/html",
})
return
}
common.ServeContentByReadSeeker(ctx.Base, path, nil, reader)
}
func ArtifactsPreviewView(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
artifactName := ctx.PathParam("artifact_name")
run, artifacts, ok := getRunAndUploadedArtifacts(ctx, runIndex, artifactName)
if !ok {
return
}
paths, err := listPreviewPaths(artifacts)
if err != nil {
ctx.ServerError("listPreviewPaths", err)
return
}
selectedPath := choosePreviewPath(paths, normalizeArtifactPreviewPath(ctx.Req.URL.Query().Get("path")))
previewFiles := make([]ArtifactPreviewFile, 0, len(paths))
for _, path := range paths {
previewFiles = append(previewFiles, ArtifactPreviewFile{
Path: path,
Selected: path == selectedPath,
})
}
runURL := run.Link()
artifactPath := url.PathEscape(artifactName)
previewURL := runURL + "/artifacts/" + artifactPath + "/preview"
ctx.Data["Title"] = ctx.Tr("preview")
ctx.Data["PageIsActions"] = true
ctx.Data["RunURL"] = runURL
ctx.Data["ArtifactName"] = artifactName
ctx.Data["PreviewURL"] = previewURL
ctx.Data["PreviewRawURL"] = previewURL + "/raw"
ctx.Data["DownloadURL"] = runURL + "/artifacts/" + artifactPath
ctx.Data["SelectedPath"] = selectedPath
ctx.Data["PreviewFiles"] = previewFiles
ctx.HTML(http.StatusOK, tplArtifactPreviewAction)
}
func ArtifactsPreviewRawView(ctx *context_module.Context) {
runIndex := getRunIndex(ctx)
artifactName := ctx.PathParam("artifact_name")
_, artifacts, ok := getRunAndUploadedArtifacts(ctx, runIndex, artifactName)
if !ok {
return
}
paths, err := listPreviewPaths(artifacts)
if err != nil {
ctx.ServerError("listPreviewPaths", err)
return
}
selectedPath := choosePreviewPath(paths, normalizeArtifactPreviewPath(ctx.Req.URL.Query().Get("path")))
if selectedPath == "" {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
artifact := artifacts[0]
buf, reader, err := openArtifactV4ZipReader(artifact)
if err != nil {
if !errors.Is(err, zip.ErrFormat) {
ctx.ServerError("openArtifactV4ZipReader", err)
return
}
fallbackPath := artifactPreviewFallbackPath(artifact)
if selectedPath != fallbackPath {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
ctx.ServerError("ActionsArtifacts.Open", err)
return
}
defer f.Close()
previewArtifactByReadSeeker(ctx, selectedPath, f)
return
}
defer buf.Close()
_, files := listArtifactV4ZipFiles(reader)
zf, ok := files[selectedPath]
if !ok {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
r, err := zf.Open()
if err != nil {
ctx.ServerError("zip.File.Open", err)
return
}
defer r.Close()
previewArtifactByReader(ctx, selectedPath, int64(zf.UncompressedSize64), r)
return
}
legacyByPath := make(map[string]*actions_model.ActionArtifact, len(artifacts))
for _, artifact := range artifacts {
path := artifactPreviewFallbackPath(artifact)
if _, ok := legacyByPath[path]; ok {
continue
}
legacyByPath[path] = artifact
}
artifact, ok := legacyByPath[selectedPath]
if !ok {
ctx.HTTPError(http.StatusNotFound, "artifact file not found")
return
}
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
if err != nil {
ctx.ServerError("ActionsArtifacts.Open", err)
return
}
defer f.Close()
if artifact.ContentEncoding == "gzip" {
r, err := gzip.NewReader(f)
if err != nil {
ctx.ServerError("gzip.NewReader", err)
return
}
defer r.Close()
previewArtifactByReader(ctx, selectedPath, artifact.FileSize, r)
return
}
previewArtifactByReadSeeker(ctx, selectedPath, f)
}
func ArtifactsDeleteView(ctx *context_module.Context) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
@ -692,36 +1058,14 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
}
func ArtifactsDownloadView(ctx *context_module.Context) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return
}
runIndex := getRunIndex(ctx)
artifactName := ctx.PathParam("artifact_name")
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: run.ID,
ArtifactName: artifactName,
})
if err != nil {
ctx.ServerError("FindArtifacts", err)
return
}
if len(artifacts) == 0 {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
_, artifacts, ok := getRunAndUploadedArtifacts(ctx, runIndex, artifactName)
if !ok {
return
}
// if artifacts status is not uploaded-confirmed, treat it as not found
for _, art := range artifacts {
if art.Status != actions_model.ArtifactStatusUploadConfirmed {
ctx.HTTPError(http.StatusNotFound, "artifact not found")
return
}
}
// A v4 Artifact may only contain a single file
// Multiple files are uploaded as a single file archive
// All other cases fall back to the legacy v1v3 zip handling below
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName))
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
err := actions.DownloadArtifactV4(ctx.Base, artifacts[0])
if err != nil {

View File

@ -1545,6 +1545,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/approve", reqRepoActionsWriter, actions.Approve)
m.Post("/delete", reqRepoActionsWriter, actions.Delete)
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
m.Get("/artifacts/{artifact_name}/preview", actions.ArtifactsPreviewView)
m.Get("/artifacts/{artifact_name}/preview/raw", actions.ArtifactsPreviewRawView)
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
m.Post("/rerun-failed", reqRepoActionsWriter, actions.RerunFailed)
@ -1748,6 +1750,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Any("/mail-preview/*", devtest.MailPreviewRender)
m.Any("/{sub}", devtest.TmplCommon)
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/artifacts/{artifact_name}", devtest.MockActionsArtifactDownload)
m.Get("/repo-action-view/runs/{run}/artifacts/{artifact_name}/preview", devtest.MockActionsArtifactPreview)
m.Get("/repo-action-view/runs/{run}/artifacts/{artifact_name}/preview/raw", devtest.MockActionsArtifactPreviewRaw)
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)

View File

@ -0,0 +1,79 @@
{{template "base/head" .}}
<div class="page-content repository actions">
{{template "repo/header" .}}
<div class="ui container artifact-preview-page">
<div class="artifact-preview-header">
<div class="artifact-preview-title">
<h2 class="ui header">{{ctx.Locale.Tr "preview"}}: <span class="gt-ellipsis">{{.ArtifactName}}</span></h2>
<div class="artifact-preview-subtitle">
<a href="{{.RunURL}}">{{ctx.Locale.Tr "go_back"}}</a>
</div>
</div>
<a class="ui button" href="{{.DownloadURL}}">
{{svg "octicon-download"}}
{{ctx.Locale.Tr "repo.download_file"}}
</a>
</div>
<div class="ui stackable grid">
<div class="four wide column">
<div class="ui fluid vertical menu">
<div class="item">
<b>{{ctx.Locale.Tr "files"}}</b>
</div>
{{range .PreviewFiles}}
<a class="item gt-ellipsis {{if .Selected}}active{{end}}" href="{{$.PreviewURL}}?path={{QueryEscape .Path}}" title="{{.Path}}">
{{.Path}}
</a>
{{else}}
<div class="item disabled">{{ctx.Locale.Tr "none"}}</div>
{{end}}
</div>
</div>
<div class="twelve wide column">
{{if .SelectedPath}}
<iframe class="artifact-preview-frame" src="{{.PreviewRawURL}}?path={{QueryEscape .SelectedPath}}" referrerpolicy="same-origin"></iframe>
{{else}}
<div class="ui attached segment">{{ctx.Locale.Tr "none"}}</div>
{{end}}
</div>
</div>
</div>
</div>
<style>
.artifact-preview-page {
margin-top: 1rem;
}
.artifact-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.artifact-preview-title {
min-width: 0;
}
.artifact-preview-title .ui.header {
margin-bottom: 0.25rem;
}
.artifact-preview-subtitle {
color: var(--color-text-light-3);
}
.artifact-preview-frame {
width: 100%;
min-height: 70vh;
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
}
</style>
{{template "base/footer" .}}

View File

@ -31,6 +31,7 @@
data-locale-show-log-seconds="{{ctx.Locale.Tr "show_log_seconds"}}"
data-locale-show-full-screen="{{ctx.Locale.Tr "show_full_screen"}}"
data-locale-download-logs="{{ctx.Locale.Tr "download_logs"}}"
data-locale-download-file="{{ctx.Locale.Tr "repo.download_file"}}"
data-locale-logs-always-auto-scroll="{{ctx.Locale.Tr "actions.logs.always_auto_scroll"}}"
data-locale-logs-always-expand-running="{{ctx.Locale.Tr "actions.logs.always_expand_running"}}"
>

View File

@ -0,0 +1,94 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"net/http"
"net/url"
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func overwriteArtifactStorageContent(t *testing.T, artifactID int64, content []byte) {
t.Helper()
artifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: artifactID})
_, err := storage.ActionsArtifacts.Save(artifact.StoragePath, bytes.NewReader(content), int64(len(content)))
require.NoError(t, err)
}
func TestActionsArtifactPreviewSingleFile(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, "user2")
req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview", repo.FullName())
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "abc.txt")
assert.Contains(t, resp.Body.String(), "/preview/raw?path=abc.txt")
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, strings.Repeat("A", 1024), resp.Body.String())
assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain")
}
func TestActionsArtifactPreviewMultiFile(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, "user2")
req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview", repo.FullName())
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "abc.txt")
assert.Contains(t, resp.Body.String(), "xyz/def.txt")
req = NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/multi-file-download/preview/raw?path=%s", repo.FullName(), url.QueryEscape("xyz/def.txt"))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, strings.Repeat("C", 1024), resp.Body.String())
}
func TestActionsArtifactPreviewUnsupportedType(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
overwriteArtifactStorageContent(t, 1, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x00, 0x00, 0x0d})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, "user2")
req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
session.MakeRequest(t, req, http.StatusUnsupportedMediaType)
}
func TestActionsArtifactPreviewHTMLSandboxCSP(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
overwriteArtifactStorageContent(t, 1, []byte("<html><body><h1>artifact</h1></body></html>"))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, "user2")
req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download/preview/raw", repo.FullName())
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Header().Get("Content-Security-Policy"), "sandbox")
assert.Contains(t, resp.Header().Get("Content-Type"), "text/html")
}
func TestActionsArtifactDownloadViewUnchanged(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, "user2")
req := NewRequestf(t, "GET", "/%s/actions/runs/187/artifacts/artifact-download", repo.FullName())
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Header().Get("Content-Disposition"), "attachment; filename=artifact-download.zip")
}

View File

@ -121,13 +121,18 @@ async function deleteArtifact(name: string) {
<template v-for="artifact in artifacts" :key="artifact.name">
<li class="job-artifacts-item">
<template v-if="artifact.status !== 'expired'">
<a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
<a class="flex-text-inline" target="_blank" :href="artifactPreviewURL(artifact.name)">
<SvgIcon name="octicon-file" class="tw-text-text"/>
<span class="gt-ellipsis">{{ artifact.name }}</span>
</a>
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash" class="tw-text-text"/>
</a>
<span class="job-artifact-actions">
<a target="_blank" :href="artifactDownloadURL(artifact.name)" :data-tooltip-content="locale.downloadFile">
<SvgIcon name="octicon-download" class="tw-text-text"/>
</a>
<a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash" class="tw-text-text"/>
</a>
</span>
</template>
<span v-else class="flex-text-inline tw-text-grey-light">
<SvgIcon name="octicon-file"/>
@ -252,6 +257,13 @@ async function deleteArtifact(name: string) {
align-items: center;
}
.job-artifact-actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.job-artifacts-list {
padding-left: 4px;
list-style: none;

View File

@ -38,6 +38,7 @@ export function initRepositoryActionView() {
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'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),