code.gitea.io/gitea@v1.22.3/modules/httplib/serve.go (about) 1 // Copyright 2023 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package httplib 5 6 import ( 7 "bytes" 8 "errors" 9 "fmt" 10 "io" 11 "net/http" 12 "net/url" 13 "path" 14 "path/filepath" 15 "strconv" 16 "strings" 17 "time" 18 19 charsetModule "code.gitea.io/gitea/modules/charset" 20 "code.gitea.io/gitea/modules/container" 21 "code.gitea.io/gitea/modules/httpcache" 22 "code.gitea.io/gitea/modules/log" 23 "code.gitea.io/gitea/modules/setting" 24 "code.gitea.io/gitea/modules/typesniffer" 25 "code.gitea.io/gitea/modules/util" 26 27 "github.com/klauspost/compress/gzhttp" 28 ) 29 30 type ServeHeaderOptions struct { 31 ContentType string // defaults to "application/octet-stream" 32 ContentTypeCharset string 33 ContentLength *int64 34 Disposition string // defaults to "attachment" 35 Filename string 36 CacheDuration time.Duration // defaults to 5 minutes 37 LastModified time.Time 38 } 39 40 // ServeSetHeaders sets necessary content serve headers 41 func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { 42 header := w.Header() 43 44 skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp") 45 if skipCompressionExts.Contains(strings.ToLower(path.Ext(opts.Filename))) { 46 w.Header().Add(gzhttp.HeaderNoCompression, "1") 47 } 48 49 contentType := typesniffer.ApplicationOctetStream 50 if opts.ContentType != "" { 51 if opts.ContentTypeCharset != "" { 52 contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset) 53 } else { 54 contentType = opts.ContentType 55 } 56 } 57 header.Set("Content-Type", contentType) 58 header.Set("X-Content-Type-Options", "nosniff") 59 60 if opts.ContentLength != nil { 61 header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) 62 } 63 64 if opts.Filename != "" { 65 disposition := opts.Disposition 66 if disposition == "" { 67 disposition = "attachment" 68 } 69 70 backslashEscapedName := strings.ReplaceAll(strings.ReplaceAll(opts.Filename, `\`, `\\`), `"`, `\"`) // \ -> \\, " -> \" 71 header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, backslashEscapedName, url.PathEscape(opts.Filename))) 72 header.Set("Access-Control-Expose-Headers", "Content-Disposition") 73 } 74 75 duration := opts.CacheDuration 76 if duration == 0 { 77 duration = 5 * time.Minute 78 } 79 httpcache.SetCacheControlInHeader(header, duration) 80 81 if !opts.LastModified.IsZero() { 82 // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat 83 header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat)) 84 } 85 } 86 87 // ServeData download file from io.Reader 88 func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, filePath string, mineBuf []byte) { 89 // do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests 90 opts := &ServeHeaderOptions{ 91 Filename: path.Base(filePath), 92 } 93 94 sniffedType := typesniffer.DetectContentType(mineBuf) 95 96 // the "render" parameter came from year 2016: 638dd24c, it doesn't have clear meaning, so I think it could be removed later 97 isPlain := sniffedType.IsText() || r.FormValue("render") != "" 98 99 if setting.MimeTypeMap.Enabled { 100 fileExtension := strings.ToLower(filepath.Ext(filePath)) 101 opts.ContentType = setting.MimeTypeMap.Map[fileExtension] 102 } 103 104 if opts.ContentType == "" { 105 if sniffedType.IsBrowsableBinaryType() { 106 opts.ContentType = sniffedType.GetMimeType() 107 } else if isPlain { 108 opts.ContentType = "text/plain" 109 } else { 110 opts.ContentType = typesniffer.ApplicationOctetStream 111 } 112 } 113 114 if isPlain { 115 charset, err := charsetModule.DetectEncoding(mineBuf) 116 if err != nil { 117 log.Error("Detect raw file %s charset failed: %v, using by default utf-8", filePath, err) 118 charset = "utf-8" 119 } 120 opts.ContentTypeCharset = strings.ToLower(charset) 121 } 122 123 isSVG := sniffedType.IsSvgImage() 124 125 // serve types that can present a security risk with CSP 126 if isSVG { 127 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") 128 } else if sniffedType.IsPDF() { 129 // no sandbox attribute for pdf as it breaks rendering in at least safari. this 130 // should generally be safe as scripts inside PDF can not escape the PDF document 131 // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion 132 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") 133 } 134 135 opts.Disposition = "inline" 136 if isSVG && !setting.UI.SVG.Enabled { 137 opts.Disposition = "attachment" 138 } 139 140 ServeSetHeaders(w, opts) 141 } 142 143 const mimeDetectionBufferLen = 1024 144 145 func ServeContentByReader(r *http.Request, w http.ResponseWriter, filePath string, size int64, reader io.Reader) { 146 buf := make([]byte, mimeDetectionBufferLen) 147 n, err := util.ReadAtMost(reader, buf) 148 if err != nil { 149 http.Error(w, "serve content: unable to pre-read", http.StatusRequestedRangeNotSatisfiable) 150 return 151 } 152 if n >= 0 { 153 buf = buf[:n] 154 } 155 setServeHeadersByFile(r, w, filePath, buf) 156 157 // reset the reader to the beginning 158 reader = io.MultiReader(bytes.NewReader(buf), reader) 159 160 rangeHeader := r.Header.Get("Range") 161 162 // if no size or no supported range, serve as 200 (complete response) 163 if size <= 0 || !strings.HasPrefix(rangeHeader, "bytes=") { 164 if size >= 0 { 165 w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 166 } 167 _, _ = io.Copy(w, reader) // just like http.ServeContent, not necessary to handle the error 168 return 169 } 170 171 // do our best to support the minimal "Range" request (no support for multiple range: "Range: bytes=0-50, 100-150") 172 // 173 // GET /... 174 // Range: bytes=0-1023 175 // 176 // HTTP/1.1 206 Partial Content 177 // Content-Range: bytes 0-1023/146515 178 // Content-Length: 1024 179 180 _, rangeParts, _ := strings.Cut(rangeHeader, "=") 181 rangeBytesStart, rangeBytesEnd, found := strings.Cut(rangeParts, "-") 182 start, err := strconv.ParseInt(rangeBytesStart, 10, 64) 183 if start < 0 || start >= size { 184 err = errors.New("invalid start range") 185 } 186 if err != nil { 187 http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable) 188 return 189 } 190 end, err := strconv.ParseInt(rangeBytesEnd, 10, 64) 191 if rangeBytesEnd == "" && found { 192 err = nil 193 end = size - 1 194 } 195 if end >= size { 196 end = size - 1 197 } 198 if end < start { 199 err = errors.New("invalid end range") 200 } 201 if err != nil { 202 http.Error(w, err.Error(), http.StatusBadRequest) 203 return 204 } 205 206 partialLength := end - start + 1 207 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) 208 w.Header().Set("Content-Length", strconv.FormatInt(partialLength, 10)) 209 if _, err = io.CopyN(io.Discard, reader, start); err != nil { 210 http.Error(w, "serve content: unable to skip", http.StatusInternalServerError) 211 return 212 } 213 214 w.WriteHeader(http.StatusPartialContent) 215 _, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error 216 } 217 218 func ServeContentByReadSeeker(r *http.Request, w http.ResponseWriter, filePath string, modTime *time.Time, reader io.ReadSeeker) { 219 buf := make([]byte, mimeDetectionBufferLen) 220 n, err := util.ReadAtMost(reader, buf) 221 if err != nil { 222 http.Error(w, "serve content: unable to read", http.StatusInternalServerError) 223 return 224 } 225 if _, err = reader.Seek(0, io.SeekStart); err != nil { 226 http.Error(w, "serve content: unable to seek", http.StatusInternalServerError) 227 return 228 } 229 if n >= 0 { 230 buf = buf[:n] 231 } 232 setServeHeadersByFile(r, w, filePath, buf) 233 if modTime == nil { 234 modTime = &time.Time{} 235 } 236 http.ServeContent(w, r, path.Base(filePath), *modTime, reader) 237 }