Feature non-zipped actions artifacts (action v7) (#36786)

- content_encoding contains a slash => v4 artifact
- updated proto files to support mime_type and no longer return errors for upload-artifact v7
- json and txt files are now previewed in browser
- normalized content-disposition header creation
- azure blob storage uploads directly in servedirect mode (no proxying data)
- normalize content-disposition headers based on go mime package
  - getting both filename and filename* encoding is done via custom code

Closes #36829

-----

Signed-off-by: ChristopherHX <christopher.homberger@web.de>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ChristopherHX
2026-03-25 17:37:48 +01:00
committed by GitHub
parent 435123fe65
commit bc5c554072
29 changed files with 1003 additions and 826 deletions
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httplib
import (
"mime"
"strings"
"code.gitea.io/gitea/modules/setting"
)
type ContentDispositionType string
const (
ContentDispositionInline ContentDispositionType = "inline"
ContentDispositionAttachment ContentDispositionType = "attachment"
)
func needsEncodingRune(b rune) bool {
return (b < ' ' || b > '~') && b != '\t'
}
// getSafeName replaces all invalid chars in the filename field by underscore
func getSafeName(s string) (_ string, needsEncoding bool) {
var out strings.Builder
for _, b := range s {
if needsEncodingRune(b) {
needsEncoding = true
out.WriteRune('_')
} else {
out.WriteRune(b)
}
}
return out.String(), needsEncoding
}
func EncodeContentDispositionAttachment(filename string) string {
return encodeContentDisposition(ContentDispositionAttachment, filename)
}
func EncodeContentDispositionInline(filename string) string {
return encodeContentDisposition(ContentDispositionInline, filename)
}
// encodeContentDisposition encodes a correct Content-Disposition Header
func encodeContentDisposition(t ContentDispositionType, filename string) string {
safeFilename, needsEncoding := getSafeName(filename)
result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename})
// No need for the utf8 encoding
if !needsEncoding {
return result
}
utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename})
// The mime package might have unexpected results in other go versions
// Make tests instance fail, otherwise use the default behavior of the go mime package
if !strings.HasPrefix(result, string(t)+"; filename=") || !strings.HasPrefix(utf8Result, string(t)+"; filename*=") {
setting.PanicInDevOrTesting("Unexpected mime package result %s", result)
return utf8Result
}
encodedFileName := strings.TrimPrefix(utf8Result, string(t))
return result + encodedFileName
}
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httplib
import (
"mime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestContentDisposition(t *testing.T) {
type testEntry struct {
disposition ContentDispositionType
filename string
header string
}
table := []testEntry{
{disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"},
{disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"},
{disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"},
{disposition: ContentDispositionInline, filename: "\"test.txt", header: "inline; filename=\"\\\"test.txt\""},
{disposition: ContentDispositionInline, filename: "hello\tworld.txt", header: "inline; filename=\"hello\tworld.txt\""},
{disposition: ContentDispositionAttachment, filename: "hello\tworld.txt", header: "attachment; filename=\"hello\tworld.txt\""},
{disposition: ContentDispositionAttachment, filename: "hello\nworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Aworld.txt"},
{disposition: ContentDispositionAttachment, filename: "hello\rworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Dworld.txt"},
}
// Check the needsEncodingRune replacer ranges except tab that is checked above
// Any change in behavior should fail here
for c := ' '; !needsEncodingRune(c); c++ {
var header string
switch {
case strings.ContainsAny(string(c), ` (),/:;<=>?@[]`):
header = "inline; filename=\"hello" + string(c) + "world.txt\""
case strings.ContainsAny(string(c), `"\`):
// This document advises against for backslash in quoted form:
// https://datatracker.ietf.org/doc/html/rfc6266#appendix-D
// However the mime package is not generating the filename* in this scenario
header = "inline; filename=\"hello\\" + string(c) + "world.txt\""
default:
header = "inline; filename=hello" + string(c) + "world.txt"
}
table = append(table, testEntry{
disposition: ContentDispositionInline,
filename: "hello" + string(c) + "world.txt",
header: header,
})
}
for _, entry := range table {
t.Run(string(entry.disposition)+"_"+entry.filename, func(t *testing.T) {
encoded := encodeContentDisposition(entry.disposition, entry.filename)
assert.Equal(t, entry.header, encoded)
disposition, params, err := mime.ParseMediaType(encoded)
require.NoError(t, err)
assert.Equal(t, string(entry.disposition), disposition)
assert.Equal(t, entry.filename, params["filename"])
})
}
}
+61 -73
View File
@@ -8,10 +8,9 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
"time"
@@ -27,18 +26,19 @@ import (
)
type ServeHeaderOptions struct {
ContentType string // defaults to "application/octet-stream"
ContentTypeCharset string
ContentLength *int64
Disposition string // defaults to "attachment"
ContentType string // defaults to "application/octet-stream"
ContentLength *int64
Filename string
CacheIsPublic bool
CacheDuration time.Duration // defaults to 5 minutes
LastModified time.Time
ContentDisposition ContentDispositionType
CacheIsPublic bool
CacheDuration time.Duration // defaults to 5 minutes
LastModified time.Time
}
// ServeSetHeaders sets necessary content serve headers
func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
header := w.Header()
skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp")
@@ -46,14 +46,7 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
w.Header().Add(gzhttp.HeaderNoCompression, "1")
}
contentType := typesniffer.MimeTypeApplicationOctetStream
if opts.ContentType != "" {
if opts.ContentTypeCharset != "" {
contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset)
} else {
contentType = opts.ContentType
}
}
contentType := util.IfZero(opts.ContentType, typesniffer.MimeTypeApplicationOctetStream)
header.Set("Content-Type", contentType)
header.Set("X-Content-Type-Options", "nosniff")
@@ -61,14 +54,18 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
}
if opts.Filename != "" {
disposition := opts.Disposition
if disposition == "" {
disposition = "attachment"
}
// 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") {
// 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'")
}
backslashEscapedName := strings.ReplaceAll(strings.ReplaceAll(opts.Filename, `\`, `\\`), `"`, `\"`) // \ -> \\, " -> \"
header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, backslashEscapedName, url.PathEscape(opts.Filename)))
if opts.Filename != "" && opts.ContentDisposition != "" {
header.Set("Content-Disposition", encodeContentDisposition(opts.ContentDisposition, path.Base(opts.Filename)))
header.Set("Access-Control-Expose-Headers", "Content-Disposition")
}
@@ -84,49 +81,40 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) {
}
}
// ServeData download file from io.Reader
func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byte, opts *ServeHeaderOptions) {
// do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests
sniffedType := typesniffer.DetectContentType(mineBuf)
// the "render" parameter came from year 2016: 638dd24c, it doesn't have clear meaning, so I think it could be removed later
isPlain := sniffedType.IsText() || r.FormValue("render") != ""
func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) {
var detectCharset bool
if setting.MimeTypeMap.Enabled {
fileExtension := strings.ToLower(filepath.Ext(opts.Filename))
fileExtension := strings.ToLower(path.Ext(opts.Filename))
opts.ContentType = setting.MimeTypeMap.Map[fileExtension]
detectCharset = !strings.Contains(opts.ContentType, "charset=")
}
if opts.ContentType == "" {
sniffedType := typesniffer.DetectContentType(contentPrefetchBuf)
if sniffedType.IsBrowsableBinaryType() {
opts.ContentType = sniffedType.GetMimeType()
} else if isPlain {
} else if sniffedType.IsText() {
// intentionally do not render user's HTML content as a page, for safety, and avoid content spamming & abusing
opts.ContentType = "text/plain"
detectCharset = true
} else {
opts.ContentType = typesniffer.MimeTypeApplicationOctetStream
}
}
if isPlain {
charset, _ := charsetModule.DetectEncoding(mineBuf)
opts.ContentTypeCharset = strings.ToLower(charset)
if detectCharset {
if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" {
opts.ContentType += "; charset=" + strings.ToLower(charset)
}
}
// serve types that can present a security risk with CSP
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
if sniffedType.IsPDF() {
// 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
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
// TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE
opts.Disposition = "inline"
if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
opts.Disposition = "attachment"
if opts.ContentDisposition == "" {
sniffedType := typesniffer.FromContentType(opts.ContentType)
opts.ContentDisposition = ContentDispositionInline
if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
opts.ContentDisposition = ContentDispositionAttachment
}
}
ServeSetHeaders(w, opts)
@@ -134,7 +122,10 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
const mimeDetectionBufferLen = 1024
func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts *ServeHeaderOptions) {
func ServeUserContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) {
if opts.ContentLength != nil {
panic("do not set ContentLength, use size argument instead")
}
buf := make([]byte, mimeDetectionBufferLen)
n, err := util.ReadAtMost(reader, buf)
if err != nil {
@@ -144,7 +135,7 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re
if n >= 0 {
buf = buf[:n]
}
setServeHeadersByFile(r, w, buf, opts)
serveSetHeadersByUserContent(w, buf, opts)
// reset the reader to the beginning
reader = io.MultiReader(bytes.NewReader(buf), reader)
@@ -198,32 +189,29 @@ func ServeContentByReader(r *http.Request, w http.ResponseWriter, size int64, re
partialLength := end - start + 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
w.Header().Set("Content-Length", strconv.FormatInt(partialLength, 10))
if _, err = io.CopyN(io.Discard, reader, start); err != nil {
http.Error(w, "serve content: unable to skip", http.StatusInternalServerError)
return
if seeker, ok := reader.(io.Seeker); ok {
if _, err = seeker.Seek(start, io.SeekStart); err != nil {
http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
return
}
} else {
if _, err = io.CopyN(io.Discard, reader, start); err != nil {
http.Error(w, "serve content: unable to skip", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusPartialContent)
_, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error
}
func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, modTime *time.Time, reader io.ReadSeeker, opts *ServeHeaderOptions) {
buf := make([]byte, mimeDetectionBufferLen)
n, err := util.ReadAtMost(reader, buf)
func ServeUserContentByFile(r *http.Request, w http.ResponseWriter, file fs.File, opts ServeHeaderOptions) {
info, err := file.Stat()
if err != nil {
http.Error(w, "serve content: unable to read", http.StatusInternalServerError)
http.Error(w, "unable to serve file, stat error", http.StatusInternalServerError)
return
}
if _, err = reader.Seek(0, io.SeekStart); err != nil {
http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
return
}
if n >= 0 {
buf = buf[:n]
}
setServeHeadersByFile(r, w, buf, opts)
if modTime == nil {
modTime = &time.Time{}
}
http.ServeContent(w, r, opts.Filename, *modTime, reader)
opts.LastModified = info.ModTime()
ServeUserContentByReader(r, w, info.Size(), file, opts)
}
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestServeContentByReader(t *testing.T) {
func TestServeUserContentByReader(t *testing.T) {
data := "0123456789abcdef"
test := func(t *testing.T, expectedStatusCode int, expectedContent string) {
@@ -27,7 +27,7 @@ func TestServeContentByReader(t *testing.T) {
}
reader := strings.NewReader(data)
w := httptest.NewRecorder()
ServeContentByReader(r, w, int64(len(data)), reader, &ServeHeaderOptions{})
ServeUserContentByReader(r, w, int64(len(data)), reader, ServeHeaderOptions{})
assert.Equal(t, expectedStatusCode, w.Code)
if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length"))
@@ -58,7 +58,7 @@ func TestServeContentByReader(t *testing.T) {
})
}
func TestServeContentByReadSeeker(t *testing.T) {
func TestServeUserContentByFile(t *testing.T) {
data := "0123456789abcdef"
tmpFile := t.TempDir() + "/test"
err := os.WriteFile(tmpFile, []byte(data), 0o644)
@@ -76,7 +76,7 @@ func TestServeContentByReadSeeker(t *testing.T) {
defer seekReader.Close()
w := httptest.NewRecorder()
ServeContentByReadSeeker(r, w, nil, seekReader, &ServeHeaderOptions{})
ServeUserContentByFile(r, w, seekReader, ServeHeaderOptions{})
assert.Equal(t, expectedStatusCode, w.Code)
if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length"))