Rework raw file http header logic (#20484)

- Always respect the user's configured mime type map
- Allow more types like image/pdf/video/audio to serve with correct content-type
- Shorten cache duration of raw files to 5 minutes, matching GitHub
- Don't set `content-disposition: attachment`, let the browser decide whether it wants to download or display a file directly
- Implement rfc5987 for filenames, remove previous hack. Confirmed it working in Safari.
- Make PDF attachment work in Safari by removing `sandbox` attribute.

This change will make a lot more file types open directly in browser now. Logic should generally be more readable than before with less `if` nesting and such.

Replaces: https://github.com/go-gitea/gitea/pull/20460
Replaces: https://github.com/go-gitea/gitea/pull/20455
Fixes: https://github.com/go-gitea/gitea/issues/20404
forgejo
silverwind 2 years ago committed by GitHub
parent 7fe77f0167
commit eaf653f843
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -70,6 +70,16 @@ func (ct SniffedType) IsRepresentableAsText() bool {
return ct.IsText() || ct.IsSvgImage() return ct.IsText() || ct.IsSvgImage()
} }
// IsBrowsableType returns whether a non-text type can be displayed in a browser
func (ct SniffedType) IsBrowsableBinaryType() bool {
return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio()
}
// GetMimeType returns the mime type
func (ct SniffedType) GetMimeType() string {
return strings.SplitN(ct.contentType, ";", 2)[0]
}
// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty. // DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty.
func DetectContentType(data []byte) SniffedType { func DetectContentType(data []byte) SniffedType {
if len(data) == 0 { if len(data) == 0 {

@ -7,12 +7,13 @@ package common
import ( import (
"fmt" "fmt"
"io" "io"
"net/url"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"code.gitea.io/gitea/modules/charset" charsetModule "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache" "code.gitea.io/gitea/modules/httpcache"
@ -42,7 +43,7 @@ func ServeBlob(ctx *context.Context, blob *git.Blob, lastModified time.Time) err
} }
// ServeData download file from io.Reader // ServeData download file from io.Reader
func ServeData(ctx *context.Context, name string, size int64, reader io.Reader) error { func ServeData(ctx *context.Context, filePath string, size int64, reader io.Reader) error {
buf := make([]byte, 1024) buf := make([]byte, 1024)
n, err := util.ReadAtMost(reader, buf) n, err := util.ReadAtMost(reader, buf)
if err != nil { if err != nil {
@ -52,56 +53,73 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader)
buf = buf[:n] buf = buf[:n]
} }
ctx.Resp.Header().Set("Cache-Control", "public,max-age=86400") httpcache.AddCacheControlToHeader(ctx.Resp.Header(), 5*time.Minute)
if size >= 0 { if size >= 0 {
ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size)) ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", size))
} else { } else {
log.Error("ServeData called to serve data: %s with size < 0: %d", name, size) log.Error("ServeData called to serve data: %s with size < 0: %d", filePath, size)
} }
name = path.Base(name)
// Google Chrome dislike commas in filenames, so let's change it to a space fileName := path.Base(filePath)
name = strings.ReplaceAll(name, ",", " ") sniffedType := typesniffer.DetectContentType(buf)
isPlain := sniffedType.IsText() || ctx.FormBool("render")
mimeType := ""
charset := ""
st := typesniffer.DetectContentType(buf)
mappedMimeType := ""
if setting.MimeTypeMap.Enabled { if setting.MimeTypeMap.Enabled {
fileExtension := strings.ToLower(filepath.Ext(name)) fileExtension := strings.ToLower(filepath.Ext(fileName))
mappedMimeType = setting.MimeTypeMap.Map[fileExtension] mimeType = setting.MimeTypeMap.Map[fileExtension]
} }
if st.IsText() || ctx.FormBool("render") {
cs, err := charset.DetectEncoding(buf) if mimeType == "" {
if err != nil { if sniffedType.IsBrowsableBinaryType() {
log.Error("Detect raw file %s charset failed: %v, using by default utf-8", name, err) mimeType = sniffedType.GetMimeType()
cs = "utf-8" } else if isPlain {
mimeType = "text/plain"
} else {
mimeType = typesniffer.ApplicationOctetStream
} }
if mappedMimeType == "" { }
mappedMimeType = "text/plain"
if isPlain {
charset, err = charsetModule.DetectEncoding(buf)
if err != nil {
log.Error("Detect raw file %s charset failed: %v, using by default utf-8", filePath, err)
charset = "utf-8"
} }
ctx.Resp.Header().Set("Content-Type", mappedMimeType+"; charset="+strings.ToLower(cs)) }
if charset != "" {
ctx.Resp.Header().Set("Content-Type", mimeType+"; charset="+strings.ToLower(charset))
} else { } else {
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") ctx.Resp.Header().Set("Content-Type", mimeType)
if mappedMimeType != "" { }
ctx.Resp.Header().Set("Content-Type", mappedMimeType) ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff")
}
if (st.IsImage() || st.IsPDF()) && (setting.UI.SVG.Enabled || !st.IsSvgImage()) { isSVG := sniffedType.IsSvgImage()
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name))
if st.IsSvgImage() || st.IsPDF() { // serve types that can present a security risk with CSP
ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") if isSVG {
ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff") ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
if st.IsSvgImage() { } else if sniffedType.IsPDF() {
ctx.Resp.Header().Set("Content-Type", typesniffer.SvgMimeType) // no sandbox attribute for pdf as it breaks rendering in at least safari. this
} else { // should generally be safe as scripts inside PDF can not escape the PDF document
ctx.Resp.Header().Set("Content-Type", typesniffer.ApplicationOctetStream) // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
} ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
} else {
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
}
} }
disposition := "inline"
if isSVG && !setting.UI.SVG.Enabled {
disposition = "attachment"
}
// encode filename per https://datatracker.ietf.org/doc/html/rfc5987
encodedFileName := `filename*=UTF-8''` + url.PathEscape(fileName)
ctx.Resp.Header().Set("Content-Disposition", disposition+"; "+encodedFileName)
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
_, err = ctx.Resp.Write(buf) _, err = ctx.Resp.Write(buf)
if err != nil { if err != nil {
return err return err

Loading…
Cancel
Save