mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-10 09:41:52 +02:00
actions: allow scripts in artifact preview iframe and use minimal error pages
Allow JavaScript execution in the artifact preview iframe so that real-world coverage reports (genhtml, nyc, jest-lcov-reporter, etc.) that require JS to render are usable. The iframe uses sandbox="allow-scripts" without allow-same-origin, so scripts run in a null/opaque origin and cannot access Gitea cookies or the parent frame. For HTML artifact responses the CSP is updated to: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; sandbox allow-scripts connect-src 'none' blocks all XHR/fetch so scripts cannot exfiltrate data or make authenticated API calls. PDF and all other types keep the original strict CSP unchanged. Error responses from the raw preview endpoint now use a minimal self-contained HTML page (WritePreviewRawError) instead of Gitea's full template, so the Gitea navbar and footer are never rendered inside the preview iframe. The devtest mock LCOV artifact is updated to realistic genhtml-style HTML with inline CSS and a coverage table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1915f1c4d
commit
015eb7805c
@ -54,14 +54,25 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
|
||||
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
|
||||
}
|
||||
|
||||
// Disable script execution of HTML/SVG files, since we serve the file from the same origin as Gitea server
|
||||
header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
if strings.Contains(contentType, "application/pdf") {
|
||||
// Served files share the Gitea origin, so we sandbox them to prevent privilege escalation.
|
||||
// HTML artifacts may include JavaScript (e.g. coverage reports, genhtml output) so we allow
|
||||
// scripts for text/html but keep the origin opaque by omitting allow-same-origin: scripts
|
||||
// run in a null origin and cannot access Gitea cookies or the parent frame. connect-src
|
||||
// 'none' blocks fetch/XHR so scripts cannot exfiltrate data or make authenticated API calls.
|
||||
switch {
|
||||
case strings.Contains(contentType, "text/html"):
|
||||
// sandbox allow-scripts: scripts execute but in null origin (no allow-same-origin).
|
||||
// default-src 'self' allows artifact sub-resources (sort.js, gcov.css, etc.) to load
|
||||
// from the same preview/raw/* route without opening any external origin.
|
||||
header.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; sandbox allow-scripts")
|
||||
case strings.Contains(contentType, "application/pdf"):
|
||||
// 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
|
||||
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
|
||||
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
|
||||
header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
||||
default:
|
||||
header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
}
|
||||
|
||||
if opts.Filename != "" && opts.ContentDisposition != "" {
|
||||
|
||||
@ -41,16 +41,91 @@ var mockActionsArtifactFiles = map[string][]mockArtifactFile{
|
||||
},
|
||||
"artifact-lcov-coverage": {
|
||||
{
|
||||
Path: "coverage/index.html",
|
||||
Content: "<html><body>mock lcov coverage report</body></html>",
|
||||
Path: "coverage/index.html",
|
||||
// Realistic genhtml-style report. CSS is inlined because the raw
|
||||
// handler sniffs external .css files as text/plain, which browsers
|
||||
// refuse to apply as a stylesheet. JS is intentionally blocked by
|
||||
// the iframe sandbox="" (no allow-scripts).
|
||||
Content: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>LCOV - Coverage Report</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;font-size:14px;margin:1em}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
th,td{padding:4px 8px;text-align:left;border:1px solid #ccc}
|
||||
th{background:#4a90d9;color:#fff;cursor:pointer}
|
||||
.hi{background:#cfc}.med{background:#ffc}.lo{background:#fcc}
|
||||
.bar{display:inline-block;height:12px;background:#4a90d9}
|
||||
.pct{min-width:3em;text-align:right}
|
||||
#sort-note{color:#888;font-size:12px;margin-top:.5em}
|
||||
</style>
|
||||
<script>
|
||||
// Column sorting — blocked by iframe sandbox="" (no allow-scripts)
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.getElementById("sort-note").textContent = "JS loaded: sorting enabled";
|
||||
document.querySelectorAll("th[data-col]").forEach(function(th) {
|
||||
th.addEventListener("click", function() {
|
||||
var col = parseInt(th.getAttribute("data-col"));
|
||||
var tbody = document.querySelector("tbody");
|
||||
var rows = Array.from(tbody.querySelectorAll("tr"));
|
||||
rows.sort(function(a, b) {
|
||||
return a.cells[col].textContent.localeCompare(b.cells[col].textContent, undefined, {numeric: true});
|
||||
});
|
||||
rows.forEach(function(r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Coverage Report</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Directory/File</th>
|
||||
<th>Lines</th>
|
||||
<th>Coverage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>src/</td><td></td><td></td></tr>
|
||||
<tr class="hi"><td> <a href="main.go.html">main.go</a></td><td>120 / 132</td><td>90.9 %</td></tr>
|
||||
<tr class="med"><td> <a href="util.go.html">util.go</a></td><td>45 / 60</td><td>75.0 %</td></tr>
|
||||
<tr class="lo"><td> <a href="legacy.go.html">legacy.go</a></td><td>12 / 38</td><td>31.6 %</td></tr>
|
||||
<tr><th colspan="3">Total: 177 / 230 — 76.9 %</th></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p id="sort-note">(JS sandboxed — column sorting disabled)</p>
|
||||
</body>
|
||||
</html>`,
|
||||
},
|
||||
{
|
||||
Path: "coverage/lcov.info",
|
||||
Content: "TN:\nSF:mock.go\nend_of_record\n",
|
||||
Content: "TN:\nSF:src/main.go\nDA:1,1\nDA:2,1\nDA:10,0\nend_of_record\nTN:\nSF:src/util.go\nDA:1,1\nDA:5,0\nend_of_record\n",
|
||||
},
|
||||
{
|
||||
Path: "coverage/summary.txt",
|
||||
Content: "mock coverage summary",
|
||||
Path: "coverage/main.go.html",
|
||||
Content: `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"/><title>main.go - Coverage</title>
|
||||
<style>
|
||||
body{font-family:monospace;font-size:13px}
|
||||
.hit{background:#cfc}.miss{background:#fcc}.neutral{background:#eee}
|
||||
td:first-child{color:#888;text-align:right;padding-right:8px;user-select:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>src/main.go</h3>
|
||||
<table>
|
||||
<tr><td>1</td><td class="hit">func main() {</td></tr>
|
||||
<tr><td>2</td><td class="hit"> fmt.Println("hello")</td></tr>
|
||||
<tr><td>3</td><td class="neutral">}</td></tr>
|
||||
<tr><td>10</td><td class="miss"> unusedFunc()</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
},
|
||||
},
|
||||
"artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": {
|
||||
@ -456,13 +531,13 @@ func MockActionsArtifactPreviewRaw(ctx *context.Context) {
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
files, ok := mockActionsArtifactFiles[artifactName]
|
||||
if !ok {
|
||||
ctx.NotFound(nil)
|
||||
actions.WritePreviewRawError(ctx, http.StatusNotFound, "artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
selectedPath := actions.ChoosePreviewPath(mockArtifactFilePaths(files), actions.GetRequestedPreviewPath(ctx))
|
||||
if selectedPath == "" {
|
||||
ctx.NotFound(nil)
|
||||
actions.WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found")
|
||||
return
|
||||
}
|
||||
|
||||
@ -474,7 +549,7 @@ func MockActionsArtifactPreviewRaw(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
if selectedFile == nil {
|
||||
ctx.NotFound(nil)
|
||||
actions.WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
614
routers/web/repo/actions/view_artifact.go
Normal file
614
routers/web/repo/actions/view_artifact.go
Normal file
@ -0,0 +1,614 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/modules/actions"
|
||||
"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/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/util/filebuffer"
|
||||
context_module "code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
type ArtifactsViewItem struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Status string `json:"status"`
|
||||
ExpiresUnix int64 `json:"expiresUnix"`
|
||||
}
|
||||
|
||||
type ArtifactPreviewFile struct {
|
||||
Path string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
const (
|
||||
artifactPreviewV4ZipListCacheTTL = 10 * time.Minute
|
||||
artifactPreviewV4ZipListCacheMaxEntries = 128
|
||||
)
|
||||
|
||||
type artifactPreviewV4ZipListCacheEntry struct {
|
||||
paths []string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var artifactPreviewV4ZipListCache = struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]artifactPreviewV4ZipListCacheEntry
|
||||
order []string
|
||||
}{
|
||||
entries: map[string]artifactPreviewV4ZipListCacheEntry{},
|
||||
}
|
||||
|
||||
type readAtBySeeker struct {
|
||||
rs io.ReadSeeker
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (r *readAtBySeeker) ReadAt(p []byte, off int64) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, err := r.rs.Seek(off, io.SeekStart); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.ReadFull(r.rs, p)
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups.
|
||||
// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID.
|
||||
// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed.
|
||||
func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) {
|
||||
if ctx.FormString("attempt") == "" {
|
||||
return run.LatestAttemptID, nil
|
||||
}
|
||||
attemptNum := ctx.FormInt64("attempt")
|
||||
if attemptNum <= 0 {
|
||||
return 0, util.ErrNotExist
|
||||
}
|
||||
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return attempt.ID, nil
|
||||
}
|
||||
|
||||
func getCurrentRunAndUploadedArtifacts(ctx *context_module.Context, artifactName string) (*actions_model.ActionRun, []*actions_model.ActionArtifact, bool) {
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
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
|
||||
}
|
||||
|
||||
// GetRequestedPreviewPath reads the requested artifact preview path from a
|
||||
// request, accepting either the trailing `/preview/raw/*` path segment or a
|
||||
// `?path=` query parameter, and normalizes it to a safe relative path.
|
||||
func GetRequestedPreviewPath(ctx *context_module.Context) string {
|
||||
path := strings.TrimPrefix(ctx.PathParam("*"), "/")
|
||||
if path == "" {
|
||||
path = ctx.Req.URL.Query().Get("path")
|
||||
}
|
||||
return normalizeArtifactPreviewPath(path)
|
||||
}
|
||||
|
||||
func artifactPreviewFallbackPath(artifact *actions_model.ActionArtifact) string {
|
||||
path := normalizeArtifactPreviewPath(artifact.ArtifactPath)
|
||||
if path != "" {
|
||||
return path
|
||||
}
|
||||
return artifact.ArtifactName
|
||||
}
|
||||
|
||||
// ChoosePreviewPath resolves the preview path to render.
|
||||
// An empty `requested` means no path was specified, so the first file is selected as a default.
|
||||
// A non-empty `requested` that is not present in `paths` returns "" so callers can 404 instead of silently swapping to a different file.
|
||||
func ChoosePreviewPath(paths []string, requested string) string {
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
if requested == "" {
|
||||
return paths[0]
|
||||
}
|
||||
if util.SliceContainsString(paths, requested) {
|
||||
return requested
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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) (storage.Object, *zip.Reader, error) {
|
||||
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
reader, err := zip.NewReader(&readAtBySeeker{rs: f}, stat.Size())
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return f, 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) {
|
||||
if paths, ok := getArtifactPreviewV4ZipListFromCache(artifact); ok {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
obj, reader, err := openArtifactV4ZipReader(artifact)
|
||||
if err != nil {
|
||||
if errors.Is(err, zip.ErrFormat) {
|
||||
paths := []string{artifactPreviewFallbackPath(artifact)}
|
||||
setArtifactPreviewV4ZipListCache(artifact, paths)
|
||||
return paths, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
paths, _ := listArtifactV4ZipFiles(reader)
|
||||
setArtifactPreviewV4ZipListCache(artifact, paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func artifactPreviewV4ZipListCacheKey(artifact *actions_model.ActionArtifact) string {
|
||||
return strconv.FormatInt(artifact.ID, 10) + ":" + strconv.FormatInt(int64(artifact.UpdatedUnix), 10)
|
||||
}
|
||||
|
||||
func removeArtifactPreviewV4ZipListCacheOrderKey(order []string, key string) []string {
|
||||
for i, current := range order {
|
||||
if current != key {
|
||||
continue
|
||||
}
|
||||
return append(order[:i], order[i+1:]...)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func getArtifactPreviewV4ZipListFromCache(artifact *actions_model.ActionArtifact) ([]string, bool) {
|
||||
key := artifactPreviewV4ZipListCacheKey(artifact)
|
||||
|
||||
artifactPreviewV4ZipListCache.mu.Lock()
|
||||
entry, ok := artifactPreviewV4ZipListCache.entries[key]
|
||||
if !ok {
|
||||
artifactPreviewV4ZipListCache.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
delete(artifactPreviewV4ZipListCache.entries, key)
|
||||
artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key)
|
||||
artifactPreviewV4ZipListCache.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
paths := append([]string(nil), entry.paths...)
|
||||
artifactPreviewV4ZipListCache.mu.Unlock()
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func setArtifactPreviewV4ZipListCache(artifact *actions_model.ActionArtifact, paths []string) {
|
||||
key := artifactPreviewV4ZipListCacheKey(artifact)
|
||||
|
||||
artifactPreviewV4ZipListCache.mu.Lock()
|
||||
defer artifactPreviewV4ZipListCache.mu.Unlock()
|
||||
|
||||
if _, ok := artifactPreviewV4ZipListCache.entries[key]; ok {
|
||||
artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key)
|
||||
}
|
||||
artifactPreviewV4ZipListCache.order = append(artifactPreviewV4ZipListCache.order, key)
|
||||
artifactPreviewV4ZipListCache.entries[key] = artifactPreviewV4ZipListCacheEntry{
|
||||
paths: append([]string(nil), paths...),
|
||||
expiresAt: time.Now().Add(artifactPreviewV4ZipListCacheTTL),
|
||||
}
|
||||
|
||||
for len(artifactPreviewV4ZipListCache.entries) > artifactPreviewV4ZipListCacheMaxEntries && len(artifactPreviewV4ZipListCache.order) > 0 {
|
||||
oldestKey := artifactPreviewV4ZipListCache.order[0]
|
||||
artifactPreviewV4ZipListCache.order = artifactPreviewV4ZipListCache.order[1:]
|
||||
if _, ok := artifactPreviewV4ZipListCache.entries[oldestKey]; !ok {
|
||||
continue
|
||||
}
|
||||
delete(artifactPreviewV4ZipListCache.entries, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// WritePreviewRawError writes a minimal self-contained HTML error page directly to the response,
|
||||
// bypassing Gitea's template system so the full Gitea UI is never rendered inside the preview iframe.
|
||||
func WritePreviewRawError(ctx *context_module.Context, status int, msg string) {
|
||||
ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
ctx.Resp.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(ctx.Resp, `<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#888}p{font-size:1.1em}</style>
|
||||
</head><body><p>%s</p></body></html>`, html.EscapeString(msg))
|
||||
}
|
||||
|
||||
func previewArtifactByReader(ctx *context_module.Context, path string, reader io.Reader) {
|
||||
buf := filebuffer.New(int(setting.UI.MaxDisplayFileSize), "")
|
||||
defer buf.Close()
|
||||
if _, err := io.Copy(buf, io.LimitReader(reader, setting.UI.MaxDisplayFileSize)); err != nil {
|
||||
log.Error("artifact preview io.Copy: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact")
|
||||
return
|
||||
}
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil {
|
||||
log.Error("artifact preview Seek: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact")
|
||||
return
|
||||
}
|
||||
previewArtifactByReadSeeker(ctx, path, buf)
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error("artifact preview ReadAtMost: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact")
|
||||
return
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
log.Error("artifact preview Seek: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact")
|
||||
return
|
||||
}
|
||||
|
||||
st := typesniffer.DetectContentType(buf)
|
||||
if !isPreviewableArtifactType(st) {
|
||||
WritePreviewRawError(ctx, http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type")
|
||||
return
|
||||
}
|
||||
|
||||
// CSP sandbox is applied by httplib.ServeSetHeaders, see HINT: PDF-RENDER-SANDBOX
|
||||
ctx.ServeContent(reader, context_module.ServeHeaderOptions{
|
||||
Filename: path,
|
||||
ContentType: st.GetMimeType(),
|
||||
})
|
||||
}
|
||||
|
||||
func ArtifactsPreviewView(ctx *context_module.Context) {
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
|
||||
run, artifacts, ok := getCurrentRunAndUploadedArtifacts(ctx, artifactName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
paths, err := listPreviewPaths(artifacts)
|
||||
if err != nil {
|
||||
ctx.ServerError("listPreviewPaths", err)
|
||||
return
|
||||
}
|
||||
selectedPath := ChoosePreviewPath(paths, GetRequestedPreviewPath(ctx))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// serveArtifactV4PreviewRaw opens the v4 artifact zip once and serves a single file from it,
|
||||
// avoiding the redundant parse that listPreviewPaths would do for raw fetches.
|
||||
func serveArtifactV4PreviewRaw(ctx *context_module.Context, artifact *actions_model.ActionArtifact, requested string) {
|
||||
obj, reader, err := openArtifactV4ZipReader(artifact)
|
||||
if err != nil {
|
||||
if !errors.Is(err, zip.ErrFormat) {
|
||||
log.Error("artifact preview openArtifactV4ZipReader: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact")
|
||||
return
|
||||
}
|
||||
fallbackPath := artifactPreviewFallbackPath(artifact)
|
||||
selectedPath := ChoosePreviewPath([]string{fallbackPath}, requested)
|
||||
if selectedPath == "" {
|
||||
WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found")
|
||||
return
|
||||
}
|
||||
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
|
||||
if err != nil {
|
||||
log.Error("artifact preview ActionsArtifacts.Open: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
previewArtifactByReadSeeker(ctx, selectedPath, f)
|
||||
return
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
paths, files := listArtifactV4ZipFiles(reader)
|
||||
selectedPath := ChoosePreviewPath(paths, requested)
|
||||
if selectedPath == "" {
|
||||
WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found")
|
||||
return
|
||||
}
|
||||
zf := files[selectedPath]
|
||||
r, err := zf.Open()
|
||||
if err != nil {
|
||||
log.Error("artifact preview zip.File.Open: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact file")
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
previewArtifactByReader(ctx, selectedPath, r)
|
||||
}
|
||||
|
||||
func ArtifactsPreviewRawView(ctx *context_module.Context) {
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
|
||||
_, artifacts, ok := getCurrentRunAndUploadedArtifacts(ctx, artifactName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requested := GetRequestedPreviewPath(ctx)
|
||||
|
||||
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
|
||||
serveArtifactV4PreviewRaw(ctx, artifacts[0], requested)
|
||||
return
|
||||
}
|
||||
|
||||
paths := listPreviewPathsForLegacyArtifacts(artifacts)
|
||||
selectedPath := ChoosePreviewPath(paths, requested)
|
||||
if selectedPath == "" {
|
||||
WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found")
|
||||
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 {
|
||||
WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := storage.ActionsArtifacts.Open(artifact.StoragePath)
|
||||
if err != nil {
|
||||
log.Error("artifact preview ActionsArtifacts.Open: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if artifact.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip {
|
||||
r, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
log.Error("artifact preview gzip.NewReader: %v", err)
|
||||
WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact")
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
previewArtifactByReader(ctx, selectedPath, r)
|
||||
return
|
||||
}
|
||||
|
||||
previewArtifactByReadSeeker(ctx, selectedPath, f)
|
||||
}
|
||||
|
||||
func ArtifactsDeleteView(ctx *context_module.Context) {
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return
|
||||
}
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil {
|
||||
ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, struct{}{})
|
||||
}
|
||||
|
||||
func ArtifactsDownloadView(ctx *context_module.Context) {
|
||||
run := getCurrentRunByPathParam(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run)
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool {
|
||||
return errors.Is(err, util.ErrNotExist)
|
||||
}, err)
|
||||
return
|
||||
}
|
||||
artifactName := ctx.PathParam("artifact_name")
|
||||
artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetArtifactsByRunAttemptAndName", err)
|
||||
return
|
||||
}
|
||||
if len(artifacts) == 0 {
|
||||
ctx.HTTPError(http.StatusNotFound, "artifact not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip"))
|
||||
if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) {
|
||||
err := actions.DownloadArtifactV4(ctx.Base, artifacts[0])
|
||||
if err != nil {
|
||||
ctx.ServerError("DownloadArtifactV4", err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend
|
||||
// Those need to be zipped for download
|
||||
zipWriter := zip.NewWriter(ctx.Resp)
|
||||
defer zipWriter.Close()
|
||||
|
||||
writeArtifactToZip := func(art *actions_model.ActionArtifact) error {
|
||||
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ActionsArtifacts.Open: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var r io.ReadCloser = f
|
||||
if art.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip {
|
||||
r, err = gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip.NewReader: %w", err)
|
||||
}
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
w, err := zipWriter.Create(art.ArtifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zipWriter.Create: %w", err)
|
||||
}
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("io.Copy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, art := range artifacts {
|
||||
err := writeArtifactToZip(art)
|
||||
if err != nil {
|
||||
ctx.ServerError("writeArtifactToZip", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -49,8 +49,9 @@
|
||||
</div>
|
||||
<div class="twelve wide column">
|
||||
{{if .SelectedPath}}
|
||||
{{/* iframe sandbox="" matches the response CSP `sandbox` directive: opaque origin, no scripts, no forms, no popups. */}}
|
||||
<iframe class="artifact-preview-frame" src="{{.PreviewRawURL}}/{{PathEscapeSegments .SelectedPath}}" sandbox="" referrerpolicy="no-referrer"></iframe>
|
||||
{{/* sandbox="allow-scripts": scripts execute but in a null/opaque origin (no allow-same-origin), so they cannot
|
||||
access Gitea cookies or the parent frame. The response CSP reinforces this and blocks connect-src. */}}
|
||||
<iframe class="artifact-preview-frame" src="{{.PreviewRawURL}}/{{PathEscapeSegments .SelectedPath}}" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
|
||||
{{else}}
|
||||
<div class="ui attached segment">{{ctx.Locale.Tr "none"}}</div>
|
||||
{{end}}
|
||||
|
||||
@ -51,7 +51,7 @@ func TestActionsArtifactPreviewSingleFile(t *testing.T) {
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "abc.txt")
|
||||
assert.Contains(t, resp.Body.String(), "/preview/raw/abc.txt")
|
||||
assert.Contains(t, resp.Body.String(), `sandbox=""`)
|
||||
assert.Contains(t, resp.Body.String(), `sandbox="allow-scripts"`)
|
||||
assert.Contains(t, resp.Body.String(), `referrerpolicy="no-referrer"`)
|
||||
|
||||
req = NewRequestf(t, "GET", "/%s/actions/runs/791/artifacts/artifact-download/preview/raw", repo.FullName())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user