mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-18 07:48:48 +01:00
Refactor storage content-type handling of ServeDirectURL (#36804)
* replace raw url.Values by *storage.ServeDirectOptions * implement content-type in azblob * implement content-disposition in azblob * add tests for content types in response * http.MethodPut for azure now allows implementing servedirect uploads --------- Signed-off-by: ChristopherHX <christopher.homberger@web.de> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -20,7 +20,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool {
|
|||||||
|
|
||||||
func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) {
|
func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) {
|
||||||
if setting.Actions.ArtifactStorage.ServeDirect() {
|
if setting.Actions.ArtifactStorage.ServeDirect() {
|
||||||
u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil)
|
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil)
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
ctx.Redirect(u.String(), http.StatusFound)
|
ctx.Redirect(u.String(), http.StatusFound)
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|||||||
@@ -112,21 +112,20 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
|
|||||||
opts.ContentTypeCharset = strings.ToLower(charset)
|
opts.ContentTypeCharset = strings.ToLower(charset)
|
||||||
}
|
}
|
||||||
|
|
||||||
isSVG := sniffedType.IsSvgImage()
|
|
||||||
|
|
||||||
// serve types that can present a security risk with CSP
|
// serve types that can present a security risk with CSP
|
||||||
if isSVG {
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
|
||||||
} else if sniffedType.IsPDF() {
|
if sniffedType.IsPDF() {
|
||||||
// no sandbox attribute for pdf as it breaks rendering in at least safari. this
|
// 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
|
// 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
|
// 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
|
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE
|
||||||
opts.Disposition = "inline"
|
opts.Disposition = "inline"
|
||||||
if isSVG && !setting.UI.SVG.Enabled {
|
if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
|
||||||
opts.Disposition = "attachment"
|
opts.Disposition = "attachment"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ func (s *ContentStore) ShouldServeDirect() bool {
|
|||||||
return setting.Packages.Storage.ServeDirect()
|
return setting.Packages.Storage.ServeDirect()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams url.Values) (*url.URL, error) {
|
func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams *storage.ServeDirectOptions) (*url.URL, error) {
|
||||||
return s.store.URL(KeyToRelativePath(key), filename, method, reqParams)
|
return s.store.ServeDirectURL(KeyToRelativePath(key), filename, method, reqParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Workaround to be removed in v1.20
|
// FIXME: Workaround to be removed in v1.20
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
package public
|
package public
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of detectWellKnownMimeType
|
// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType
|
||||||
var wellKnownMimeTypesLower = map[string]string{
|
var wellKnownMimeTypesLower = map[string]string{
|
||||||
".avif": "image/avif",
|
".avif": "image/avif",
|
||||||
".css": "text/css; charset=utf-8",
|
".css": "text/css; charset=utf-8",
|
||||||
@@ -28,13 +30,13 @@ var wellKnownMimeTypesLower = map[string]string{
|
|||||||
".txt": "text/plain; charset=utf-8",
|
".txt": "text/plain; charset=utf-8",
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectWellKnownMimeType will return the mime-type for a well-known file ext name
|
// DetectWellKnownMimeType will return the mime-type for a well-known file ext name
|
||||||
// The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension
|
// The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension
|
||||||
// mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document).
|
// mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document).
|
||||||
// If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers.
|
// If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers.
|
||||||
// For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons.
|
// For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons.
|
||||||
// detectWellKnownMimeType makes the Content-Type for well-known files stable.
|
// DetectWellKnownMimeType makes the Content-Type for well-known files stable.
|
||||||
func detectWellKnownMimeType(ext string) string {
|
func DetectWellKnownMimeType(ext string) string {
|
||||||
ext = strings.ToLower(ext)
|
ext = strings.ToLower(ext)
|
||||||
return wellKnownMimeTypesLower[ext]
|
return wellKnownMimeTypesLower[ext]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ func parseAcceptEncoding(val string) container.Set[string] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setWellKnownContentType will set the Content-Type if the file is a well-known type.
|
// setWellKnownContentType will set the Content-Type if the file is a well-known type.
|
||||||
// See the comments of detectWellKnownMimeType
|
// See the comments of DetectWellKnownMimeType
|
||||||
func setWellKnownContentType(w http.ResponseWriter, file string) {
|
func setWellKnownContentType(w http.ResponseWriter, file string) {
|
||||||
mimeType := detectWellKnownMimeType(path.Ext(file))
|
mimeType := DetectWellKnownMimeType(path.Ext(file))
|
||||||
if mimeType != "" {
|
if mimeType != "" {
|
||||||
w.Header().Set("Content-Type", mimeType)
|
w.Header().Set("Content-Type", mimeType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -246,16 +247,53 @@ func (a *AzureBlobStorage) Delete(path string) error {
|
|||||||
return convertAzureBlobErr(err)
|
return convertAzureBlobErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes.
|
func (a *AzureBlobStorage) getSasURL(b *blob.Client, template sas.BlobSignatureValues) (string, error) {
|
||||||
func (a *AzureBlobStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) {
|
urlParts, err := blob.ParseURL(b.URL())
|
||||||
blobClient := a.getBlobClient(path)
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: OBJECT-STORAGE-CONTENT-TYPE: "browser inline rendering images/PDF" needs proper Content-Type header from storage
|
var t time.Time
|
||||||
startTime := time.Now()
|
if urlParts.Snapshot == "" {
|
||||||
u, err := blobClient.GetSASURL(sas.BlobPermissions{
|
t = time.Time{}
|
||||||
Read: true,
|
} else {
|
||||||
}, time.Now().Add(5*time.Minute), &blob.GetSASURLOptions{
|
t, err = time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot)
|
||||||
StartTime: &startTime,
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template.ContainerName = urlParts.ContainerName
|
||||||
|
template.BlobName = urlParts.BlobName
|
||||||
|
template.SnapshotTime = t
|
||||||
|
template.Version = sas.Version
|
||||||
|
|
||||||
|
qps, err := template.SignWithSharedKey(a.credential)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := b.URL() + "?" + qps.Encode()
|
||||||
|
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqParams *ServeDirectOptions) (*url.URL, error) {
|
||||||
|
blobClient := a.getBlobClient(storePath)
|
||||||
|
|
||||||
|
startTime := time.Now().UTC()
|
||||||
|
|
||||||
|
param := prepareServeDirectOptions(reqParams, name)
|
||||||
|
|
||||||
|
u, err := a.getSasURL(blobClient, sas.BlobSignatureValues{
|
||||||
|
Permissions: (&sas.BlobPermissions{
|
||||||
|
Read: method == http.MethodGet || method == http.MethodHead,
|
||||||
|
Write: method == http.MethodPut,
|
||||||
|
}).String(),
|
||||||
|
StartTime: startTime,
|
||||||
|
ExpiryTime: startTime.Add(5 * time.Minute),
|
||||||
|
ContentDisposition: param.ContentDisposition,
|
||||||
|
ContentType: param.ContentType,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, convertAzureBlobErr(err)
|
return nil, convertAzureBlobErr(err)
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAzureBlobStorageIterator(t *testing.T) {
|
func TestAzureBlobStorage(t *testing.T) {
|
||||||
if os.Getenv("CI") == "" {
|
if os.Getenv("CI") == "" {
|
||||||
t.Skip("azureBlobStorage not present outside of CI")
|
t.Skip("azureBlobStorage not present outside of CI")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
testStorageIterator(t, setting.AzureBlobStorageType, &setting.Storage{
|
storageType := setting.AzureBlobStorageType
|
||||||
|
config := &setting.Storage{
|
||||||
AzureBlobConfig: setting.AzureBlobStorageConfig{
|
AzureBlobConfig: setting.AzureBlobStorageConfig{
|
||||||
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
|
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
|
||||||
Endpoint: "http://devstoreaccount1.azurite.local:10000",
|
Endpoint: "http://devstoreaccount1.azurite.local:10000",
|
||||||
@@ -28,7 +29,25 @@ func TestAzureBlobStorageIterator(t *testing.T) {
|
|||||||
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
|
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
|
||||||
Container: "test",
|
Container: "test",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
table := []struct {
|
||||||
|
name string
|
||||||
|
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "iterator",
|
||||||
|
test: testStorageIterator,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||||
|
test: testBlobStorageURLContentTypeAndDisposition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, entry := range table {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
entry.test(t, storageType, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAzureBlobStoragePath(t *testing.T) {
|
func TestAzureBlobStoragePath(t *testing.T) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (s discardStorage) Delete(_ string) error {
|
|||||||
return fmt.Errorf("%s", s)
|
return fmt.Errorf("%s", s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s discardStorage) URL(_, _, _ string, _ url.Values) (*url.URL, error) {
|
func (s discardStorage) ServeDirectURL(_, _, _ string, _ *ServeDirectOptions) (*url.URL, error) {
|
||||||
return nil, fmt.Errorf("%s", s)
|
return nil, fmt.Errorf("%s", s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func Test_discardStorage(t *testing.T) {
|
|||||||
assert.Error(t, err, string(tt))
|
assert.Error(t, err, string(tt))
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
got, err := tt.URL("path", "name", "GET", nil)
|
got, err := tt.ServeDirectURL("path", "name", "GET", nil)
|
||||||
assert.Nil(t, got)
|
assert.Nil(t, got)
|
||||||
assert.Errorf(t, err, string(tt))
|
assert.Errorf(t, err, string(tt))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,8 +133,7 @@ func (l *LocalStorage) Delete(path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL gets the redirect URL to a file
|
func (l *LocalStorage) ServeDirectURL(path, name, _ string, reqParams *ServeDirectOptions) (*url.URL, error) {
|
||||||
func (l *LocalStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) {
|
|
||||||
return nil, ErrURLNotSupported
|
return nil, ErrURLNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,37 +278,16 @@ func (m *MinioStorage) Delete(path string) error {
|
|||||||
return convertMinioErr(err)
|
return convertMinioErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes.
|
func (m *MinioStorage) ServeDirectURL(storePath, name, method string, opt *ServeDirectOptions) (*url.URL, error) {
|
||||||
func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams url.Values) (*url.URL, error) {
|
reqParams := url.Values{}
|
||||||
// copy serveDirectReqParams
|
|
||||||
reqParams, err := url.ParseQuery(serveDirectReqParams.Encode())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Here we might not know the real filename, and it's quite inefficient to detect the mine type by pre-fetching the object head.
|
param := prepareServeDirectOptions(opt, name)
|
||||||
// So we just do a quick detection by extension name, at least if works for the "View Raw File" for an LFS file on the Web UI.
|
// minio does not ignore empty params
|
||||||
// Detect content type by extension name, only support the well-known safe types for inline rendering.
|
if param.ContentType != "" {
|
||||||
// TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future
|
reqParams.Set("response-content-type", param.ContentType)
|
||||||
ext := path.Ext(name)
|
|
||||||
inlineExtMimeTypes := map[string]string{
|
|
||||||
".png": "image/png",
|
|
||||||
".jpg": "image/jpeg",
|
|
||||||
".jpeg": "image/jpeg",
|
|
||||||
".gif": "image/gif",
|
|
||||||
".webp": "image/webp",
|
|
||||||
".avif": "image/avif",
|
|
||||||
// ATTENTION! Don't support unsafe types like HTML/SVG due to security concerns: they can contain JS code, and maybe they need proper Content-Security-Policy
|
|
||||||
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline
|
|
||||||
".pdf": "application/pdf",
|
|
||||||
|
|
||||||
// TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType"
|
|
||||||
}
|
}
|
||||||
if mimeType, ok := inlineExtMimeTypes[ext]; ok {
|
if param.ContentDisposition != "" {
|
||||||
reqParams.Set("response-content-type", mimeType)
|
reqParams.Set("response-content-disposition", param.ContentDisposition)
|
||||||
reqParams.Set("response-content-disposition", "inline")
|
|
||||||
} else {
|
|
||||||
reqParams.Set("response-content-disposition", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
expires := 5 * time.Minute
|
expires := 5 * time.Minute
|
||||||
@@ -323,6 +302,7 @@ func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams
|
|||||||
// IterateObjects iterates across the objects in the miniostorage
|
// IterateObjects iterates across the objects in the miniostorage
|
||||||
func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||||
opts := minio.GetObjectOptions{}
|
opts := minio.GetObjectOptions{}
|
||||||
|
// FIXME: this loop is not right and causes resource leaking, see the comment of ListObjects
|
||||||
for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{
|
for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{
|
||||||
Prefix: m.buildMinioDirPrefix(dirName),
|
Prefix: m.buildMinioDirPrefix(dirName),
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMinioStorageIterator(t *testing.T) {
|
func TestMinioStorage(t *testing.T) {
|
||||||
if os.Getenv("CI") == "" {
|
if os.Getenv("CI") == "" {
|
||||||
t.Skip("minioStorage not present outside of CI")
|
t.Skip("minioStorage not present outside of CI")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
testStorageIterator(t, setting.MinioStorageType, &setting.Storage{
|
storageType := setting.MinioStorageType
|
||||||
|
config := &setting.Storage{
|
||||||
MinioConfig: setting.MinioStorageConfig{
|
MinioConfig: setting.MinioStorageConfig{
|
||||||
Endpoint: "minio:9000",
|
Endpoint: "minio:9000",
|
||||||
AccessKeyID: "123456",
|
AccessKeyID: "123456",
|
||||||
@@ -29,7 +30,25 @@ func TestMinioStorageIterator(t *testing.T) {
|
|||||||
Bucket: "gitea",
|
Bucket: "gitea",
|
||||||
Location: "us-east-1",
|
Location: "us-east-1",
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
table := []struct {
|
||||||
|
name string
|
||||||
|
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "iterator",
|
||||||
|
test: testStorageIterator,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||||
|
test: testBlobStorageURLContentTypeAndDisposition,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, entry := range table {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
entry.test(t, storageType, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinioStoragePath(t *testing.T) {
|
func TestMinioStoragePath(t *testing.T) {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/public"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +58,38 @@ type Object interface {
|
|||||||
Stat() (os.FileInfo, error)
|
Stat() (os.FileInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeDirectOptions customizes HTTP headers for a generated signed URL.
|
||||||
|
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) {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
// ObjectStorage represents an object storage to handle a bucket and files
|
// ObjectStorage represents an object storage to handle a bucket and files
|
||||||
type ObjectStorage interface {
|
type ObjectStorage interface {
|
||||||
Open(path string) (Object, error)
|
Open(path string) (Object, error)
|
||||||
@@ -67,7 +101,15 @@ type ObjectStorage interface {
|
|||||||
|
|
||||||
Stat(path string) (os.FileInfo, error)
|
Stat(path string) (os.FileInfo, error)
|
||||||
Delete(path string) error
|
Delete(path string) error
|
||||||
URL(path, name, method string, reqParams url.Values) (*url.URL, error)
|
|
||||||
|
// ServeDirectURL generates a "serve-direct" URL for the specified blob storage file,
|
||||||
|
// end user (browser) will use this URL to access the file directly from the object storage, bypassing Gitea server.
|
||||||
|
// Usually the link is time-limited (a few minutes) and contains a signature to ensure security.
|
||||||
|
// The generated URL must NOT use the same origin as Gitea server, otherwise it will cause security issues.
|
||||||
|
// * method defines which HTTP method is permitted for certain storage providers (e.g., MinIO).
|
||||||
|
// * opt allows customizing the Content-Type and Content-Disposition headers.
|
||||||
|
// TODO: need to merge "ServeDirect()" check into this function, avoid duplicate code and potential inconsistency.
|
||||||
|
ServeDirectURL(path, name, method string, opt *ServeDirectOptions) (*url.URL, error)
|
||||||
|
|
||||||
// IterateObjects calls the iterator function for each object in the storage with the given path as prefix
|
// IterateObjects calls the iterator function for each object in the storage with the given path as prefix
|
||||||
// The "fullPath" argument in callback is the full path in this storage.
|
// The "fullPath" argument in callback is the full path in this storage.
|
||||||
@@ -136,7 +178,7 @@ var (
|
|||||||
|
|
||||||
// Actions represents actions storage
|
// Actions represents actions storage
|
||||||
Actions ObjectStorage = uninitializedStorage
|
Actions ObjectStorage = uninitializedStorage
|
||||||
// Actions Artifacts represents actions artifacts storage
|
// ActionsArtifacts Artifacts represents actions artifacts storage
|
||||||
ActionsArtifacts ObjectStorage = uninitializedStorage
|
ActionsArtifacts ObjectStorage = uninitializedStorage
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||||
@@ -50,3 +52,55 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
|||||||
assert.Len(t, expected, count)
|
assert.Len(t, expected, count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected ServeDirectOptions, reqParams *ServeDirectOptions) {
|
||||||
|
u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.Get(u.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if expected.ContentType != "" {
|
||||||
|
assert.Equal(t, expected.ContentType, resp.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if expected.ContentDisposition != "" {
|
||||||
|
assert.Equal(t, expected.ContentDisposition, resp.Header.Get("Content-Disposition"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||||
|
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)))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{
|
||||||
|
ContentType: "text/plain; charset=utf-8",
|
||||||
|
ContentDisposition: `inline; filename="test.txt"`,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
ContentDisposition: `inline; filename="test.pdf"`,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{
|
||||||
|
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"`,
|
||||||
|
}, &ServeDirectOptions{
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
ContentDisposition: `inline; filename="test.xml"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, s.Delete(testfilename))
|
||||||
|
}
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
|
|||||||
for _, artifact := range artifacts {
|
for _, artifact := range artifacts {
|
||||||
var downloadURL string
|
var downloadURL string
|
||||||
if setting.Actions.ArtifactStorage.ServeDirect() {
|
if setting.Actions.ArtifactStorage.ServeDirect() {
|
||||||
u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
|
u, err := ar.fs.ServeDirectURL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
|
||||||
if err != nil && !errors.Is(err, storage.ErrURLNotSupported) {
|
if err != nil && !errors.Is(err, storage.ErrURLNotSupported) {
|
||||||
log.Error("Error getting serve direct url: %v", err)
|
log.Error("Error getting serve direct url: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,7 +562,8 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
|
|||||||
respData := GetSignedArtifactURLResponse{}
|
respData := GetSignedArtifactURLResponse{}
|
||||||
|
|
||||||
if setting.Actions.ArtifactStorage.ServeDirect() {
|
if setting.Actions.ArtifactStorage.ServeDirect() {
|
||||||
u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, nil)
|
// DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint
|
||||||
|
u, err := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodGet, nil)
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
respData.SignedUrl = u.String()
|
respData.SignedUrl = u.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
packages_module "code.gitea.io/gitea/modules/packages"
|
packages_module "code.gitea.io/gitea/modules/packages"
|
||||||
container_module "code.gitea.io/gitea/modules/packages/container"
|
container_module "code.gitea.io/gitea/modules/packages/container"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/storage"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/routers/api/packages/helper"
|
"code.gitea.io/gitea/routers/api/packages/helper"
|
||||||
auth_service "code.gitea.io/gitea/services/auth"
|
auth_service "code.gitea.io/gitea/services/auth"
|
||||||
@@ -706,9 +707,9 @@ func DeleteManifest(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
|
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
|
||||||
serveDirectReqParams := make(url.Values)
|
s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{
|
||||||
serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType))
|
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
|
||||||
s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, serveDirectReqParams)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apiError(ctx, http.StatusInternalServerError, err)
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
|
|||||||
|
|
||||||
if setting.LFS.Storage.ServeDirect() {
|
if setting.LFS.Storage.ServeDirect() {
|
||||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||||
u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
|
u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
ctx.Redirect(u.String())
|
ctx.Redirect(u.String())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objSto
|
|||||||
// So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it.
|
// So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it.
|
||||||
// Checking "stat" requires one more request to the storage, which is inefficient.
|
// Checking "stat" requires one more request to the storage, which is inefficient.
|
||||||
// Workaround: disable "SERVE_DIRECT". Leave the problem to the future.
|
// Workaround: disable "SERVE_DIRECT". Leave the problem to the future.
|
||||||
u, err := objStore.URL(avatarPath, path.Base(avatarPath), req.Method, nil)
|
u, err := objStore.ServeDirectURL(avatarPath, path.Base(avatarPath), req.Method, nil)
|
||||||
if handleError(w, req, avatarPath, err) {
|
if handleError(w, req, avatarPath, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ func ServeAttachment(ctx *context.Context, uuid string) {
|
|||||||
|
|
||||||
if setting.Attachment.Storage.ServeDirect() {
|
if setting.Attachment.Storage.ServeDirect() {
|
||||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||||
u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
|
u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
|
||||||
|
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
ctx.Redirect(u.String())
|
ctx.Redirect(u.String())
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim
|
|||||||
|
|
||||||
if setting.LFS.Storage.ServeDirect() {
|
if setting.LFS.Storage.ServeDirect() {
|
||||||
// If we have a signed url (S3, object storage, blob storage), redirect to this directly.
|
// If we have a signed url (S3, object storage, blob storage), redirect to this directly.
|
||||||
u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
|
u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
ctx.Redirect(u.String())
|
ctx.Redirect(u.String())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ type requestContext struct {
|
|||||||
User string
|
User string
|
||||||
Repo string
|
Repo string
|
||||||
Authorization string
|
Authorization string
|
||||||
Method string
|
|
||||||
RepoGitURL string
|
RepoGitURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +426,6 @@ func getRequestContext(ctx *context.Context) *requestContext {
|
|||||||
User: ownerName,
|
User: ownerName,
|
||||||
Repo: repoName,
|
Repo: repoName,
|
||||||
Authorization: ctx.Req.Header.Get("Authorization"),
|
Authorization: ctx.Req.Header.Get("Authorization"),
|
||||||
Method: ctx.Req.Method,
|
|
||||||
RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"),
|
RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,7 +488,8 @@ func buildObjectResponse(rc *requestContext, pointer lfs_module.Pointer, downloa
|
|||||||
var link *lfs_module.Link
|
var link *lfs_module.Link
|
||||||
if setting.LFS.Storage.ServeDirect() {
|
if setting.LFS.Storage.ServeDirect() {
|
||||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||||
u, err := storage.LFS.URL(pointer.RelativePath(), pointer.Oid, rc.Method, nil)
|
// DO NOT USE the http POST method coming from the lfs batch endpoint
|
||||||
|
u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), pointer.Oid, http.MethodGet, nil)
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
link = lfs_module.NewLink(u.String()) // Presigned url does not need the Authorization header
|
link = lfs_module.NewLink(u.String()) // Presigned url does not need the Authorization header
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ func OpenBlobStream(pb *packages_model.PackageBlob) (io.ReadSeekCloser, error) {
|
|||||||
|
|
||||||
// OpenBlobForDownload returns the content of the specific package blob and increases the download counter.
|
// OpenBlobForDownload returns the content of the specific package blob and increases the download counter.
|
||||||
// If the storage supports direct serving and it's enabled, only the direct serving url is returned.
|
// If the storage supports direct serving and it's enabled, only the direct serving url is returned.
|
||||||
func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams url.Values) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) {
|
func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams *storage.ServeDirectOptions) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) {
|
||||||
key := packages_module.BlobHash256Key(pb.HashSHA256)
|
key := packages_module.BlobHash256Key(pb.HashSHA256)
|
||||||
|
|
||||||
cs := packages_module.NewContentStore()
|
cs := packages_module.NewContentStore()
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error
|
|||||||
rPath := archiver.RelativePath()
|
rPath := archiver.RelativePath()
|
||||||
if setting.RepoArchive.Storage.ServeDirect() {
|
if setting.RepoArchive.Storage.ServeDirect() {
|
||||||
// If we have a signed url (S3, object storage), redirect to this directly.
|
// If we have a signed url (S3, object storage), redirect to this directly.
|
||||||
u, err := storage.RepoArchives.URL(rPath, downloadName, ctx.Req.Method, nil)
|
u, err := storage.RepoArchives.ServeDirectURL(rPath, downloadName, ctx.Req.Method, nil)
|
||||||
if u != nil && err == nil {
|
if u != nil && err == nil {
|
||||||
ctx.Redirect(u.String())
|
ctx.Redirect(u.String())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user