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