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

View File

@@ -5,44 +5,61 @@ package actions
import (
"net/http"
"strings"
actions_model "code.gitea.io/gitea/models/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/services/context"
)
// Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend
// The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend
// IsArtifactV4 detects whether the artifact is likely from v4.
// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash
// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend.
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
return art.ArtifactName+".zip" == art.ArtifactPath && art.ContentEncoding == "application/zip"
return strings.Contains(art.ContentEncodingOrType, "/")
}
func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) {
if setting.Actions.ArtifactStorage.ServeDirect() {
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String(), http.StatusFound)
return true, nil
}
func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) {
contentType := art.ContentEncodingOrType
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType})
if err != nil {
return "", err
}
return false, nil
return u.String(), nil
}
func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error {
func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool {
if !setting.Actions.ArtifactStorage.ServeDirect() {
return false
}
u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method)
if err != nil {
log.Error("GetArtifactV4ServeDirectURL: %v", err)
return false
}
ctx.Redirect(u, http.StatusFound)
return true
}
func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
defer f.Close()
http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactName+".zip", art.CreatedUnix.AsLocalTime(), f)
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{
Filename: art.ArtifactPath,
ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type
})
return nil
}
func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art)
if ok || err != nil {
return err
if DownloadArtifactV4ServeDirect(ctx, art) {
return nil
}
return DownloadArtifactV4Fallback(ctx, art)
return DownloadArtifactV4ReadStorage(ctx, art)
}

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
}

View File

@@ -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"])
})
}
}

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)
}

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"))

View File

@@ -104,7 +104,7 @@ func (s *ContentStore) Verify(pointer Pointer) (bool, error) {
}
// ReadMetaObject will read a git_model.LFSMetaObject and return a reader
func ReadMetaObject(pointer Pointer) (io.ReadSeekCloser, error) {
func ReadMetaObject(pointer Pointer) (storage.Object, error) {
contentStore := NewContentStore()
return contentStore.Get(pointer)
}

View File

@@ -23,11 +23,7 @@ import (
"github.com/minio/minio-go/v7/pkg/credentials"
)
var (
_ ObjectStorage = &MinioStorage{}
quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
)
var _ ObjectStorage = &MinioStorage{}
type minioObject struct {
*minio.Object

View File

@@ -12,6 +12,7 @@ import (
"os"
"path"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
@@ -62,31 +63,30 @@ type Object interface {
type ServeDirectOptions struct {
// Overrides the automatically detected MIME type.
ContentType string
// Overrides the default Content-Disposition header, which is `inline; filename="name"`.
ContentDisposition string
}
// Safe defaults are applied only when not explicitly overridden by the caller.
func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret ServeDirectOptions) {
func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret struct {
ContentType string
ContentDisposition string
},
) {
// Here we might not know the real filename, and it's quite inefficient to detect the MIME type by pre-fetching the object head.
// So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI.
// TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future
if optsOptional != nil {
ret = *optsOptional
ret.ContentType = optsOptional.ContentType
}
// TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE
name = path.Base(name)
if ret.ContentType == "" {
ext := path.Ext(name)
ret.ContentType = public.DetectWellKnownMimeType(ext)
}
if ret.ContentDisposition == "" {
// When using ServeDirect, the URL is from the object storage's web server,
// it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly.
// If a browser doesn't support the content type to be displayed inline, browser will download with the filename.
ret.ContentDisposition = fmt.Sprintf(`inline; filename="%s"`, quoteEscaper.Replace(name))
}
// When using ServeDirect, the URL is from the object storage's web server,
// it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly.
// If a browser doesn't support the content type to be displayed inline, browser will download with the filename.
ret.ContentDisposition = httplib.EncodeContentDispositionInline(name)
return ret
}

View File

@@ -53,7 +53,12 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
}
}
func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected ServeDirectOptions, reqParams *ServeDirectOptions) {
type expectedServeDirectHeaders struct {
ContentType string
ContentDisposition string
}
func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected expectedServeDirectHeaders, reqParams *ServeDirectOptions) {
u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams)
require.NoError(t, err)
resp, err := http.Get(u.String())
@@ -71,36 +76,29 @@ func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg
s, err := NewStorage(typStr, cfg)
assert.NoError(t, err)
data := "Q2xTckt6Y1hDOWh0" // arbitrary test content; specific value is irrelevant to this test
testfilename := "test.txt" // arbitrary file name; specific value is irrelevant to this test
_, err = s.Save(testfilename, strings.NewReader(data), int64(len(data)))
testFilename := "test.txt"
_, err = s.Save(testFilename, strings.NewReader("dummy-content"), -1)
assert.NoError(t, err)
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.txt", expectedServeDirectHeaders{
ContentType: "text/plain; charset=utf-8",
ContentDisposition: `inline; filename="test.txt"`,
ContentDisposition: `inline; filename=test.txt`,
}, nil)
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.pdf", expectedServeDirectHeaders{
ContentType: "application/pdf",
ContentDisposition: `inline; filename="test.pdf"`,
ContentDisposition: `inline; filename=test.pdf`,
}, nil)
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{
ContentDisposition: `inline; filename="test.wasm"`,
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.wasm", expectedServeDirectHeaders{
ContentDisposition: `inline; filename=test.wasm`,
}, nil)
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{
ContentDisposition: `inline; filename="test.wasm"`,
}, &ServeDirectOptions{})
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{
ContentType: "application/octet-stream",
ContentDisposition: `inline; filename="test.xml"`,
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testFilename, "test.wasm", expectedServeDirectHeaders{
ContentType: "application/wasm",
ContentDisposition: `inline; filename=test.wasm`,
}, &ServeDirectOptions{
ContentType: "application/octet-stream",
ContentDisposition: `inline; filename="test.xml"`,
ContentType: "application/wasm",
})
assert.NoError(t, s.Delete(testfilename))
assert.NoError(t, s.Delete(testFilename))
}

View File

@@ -183,3 +183,7 @@ func DetectContentType(data []byte) SniffedType {
}
return SniffedType{ct}
}
func FromContentType(contentType string) SniffedType {
return SniffedType{contentType}
}