Skip to content

Commit 023a048

Browse files
authored
Make repository response support HTTP range request (#24592)
Replace #20480 Replace #18448 Close #16414
1 parent c090f87 commit 023a048

File tree

12 files changed

+434
-212
lines changed

12 files changed

+434
-212
lines changed

modules/context/context_serve.go

Lines changed: 5 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,71 +4,20 @@
44
package context
55

66
import (
7-
"fmt"
87
"io"
98
"net/http"
10-
"net/url"
11-
"strconv"
12-
"strings"
13-
"time"
149

15-
"code.gitea.io/gitea/modules/httpcache"
16-
"code.gitea.io/gitea/modules/typesniffer"
10+
"code.gitea.io/gitea/modules/httplib"
1711
)
1812

19-
type ServeHeaderOptions struct {
20-
ContentType string // defaults to "application/octet-stream"
21-
ContentTypeCharset string
22-
ContentLength *int64
23-
Disposition string // defaults to "attachment"
24-
Filename string
25-
CacheDuration time.Duration // defaults to 5 minutes
26-
LastModified time.Time
27-
}
28-
29-
// SetServeHeaders sets necessary content serve headers
30-
func (ctx *Context) SetServeHeaders(opts *ServeHeaderOptions) {
31-
header := ctx.Resp.Header()
32-
33-
contentType := typesniffer.ApplicationOctetStream
34-
if opts.ContentType != "" {
35-
if opts.ContentTypeCharset != "" {
36-
contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset)
37-
} else {
38-
contentType = opts.ContentType
39-
}
40-
}
41-
header.Set("Content-Type", contentType)
42-
header.Set("X-Content-Type-Options", "nosniff")
43-
44-
if opts.ContentLength != nil {
45-
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
46-
}
47-
48-
if opts.Filename != "" {
49-
disposition := opts.Disposition
50-
if disposition == "" {
51-
disposition = "attachment"
52-
}
53-
54-
backslashEscapedName := strings.ReplaceAll(strings.ReplaceAll(opts.Filename, `\`, `\\`), `"`, `\"`) // \ -> \\, " -> \"
55-
header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, backslashEscapedName, url.PathEscape(opts.Filename)))
56-
header.Set("Access-Control-Expose-Headers", "Content-Disposition")
57-
}
58-
59-
duration := opts.CacheDuration
60-
if duration == 0 {
61-
duration = 5 * time.Minute
62-
}
63-
httpcache.SetCacheControlInHeader(header, duration)
13+
type ServeHeaderOptions httplib.ServeHeaderOptions
6414

65-
if !opts.LastModified.IsZero() {
66-
header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat))
67-
}
15+
func (ctx *Context) SetServeHeaders(opt *ServeHeaderOptions) {
16+
httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opt))
6817
}
6918

7019
// ServeContent serves content to http request
7120
func (ctx *Context) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) {
72-
ctx.SetServeHeaders(opts)
21+
httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opts))
7322
http.ServeContent(ctx.Resp, ctx.Req, opts.Filename, opts.LastModified, r)
7423
}

modules/context/context_test.go

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,16 @@ import (
77
"net/http"
88
"testing"
99

10+
"code.gitea.io/gitea/modules/httplib"
1011
"code.gitea.io/gitea/modules/setting"
1112

1213
"github.com/stretchr/testify/assert"
1314
)
1415

15-
type mockResponseWriter struct {
16-
header http.Header
17-
}
18-
19-
func (m *mockResponseWriter) Header() http.Header {
20-
return m.header
21-
}
22-
23-
func (m *mockResponseWriter) Write(bytes []byte) (int, error) {
24-
panic("implement me")
25-
}
26-
27-
func (m *mockResponseWriter) WriteHeader(statusCode int) {
28-
panic("implement me")
29-
}
30-
3116
func TestRemoveSessionCookieHeader(t *testing.T) {
32-
w := &mockResponseWriter{}
33-
w.header = http.Header{}
34-
w.header.Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String())
35-
w.header.Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String())
17+
w := httplib.NewMockResponseWriter()
18+
w.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String())
19+
w.Header().Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String())
3620
assert.Len(t, w.Header().Values("Set-Cookie"), 2)
3721
removeSessionCookieHeader(w)
3822
assert.Len(t, w.Header().Values("Set-Cookie"), 1)

modules/httplib/mock.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package httplib
5+
6+
import (
7+
"bytes"
8+
"net/http"
9+
)
10+
11+
type MockResponseWriter struct {
12+
header http.Header
13+
14+
StatusCode int
15+
BodyBuffer bytes.Buffer
16+
}
17+
18+
func (m *MockResponseWriter) Header() http.Header {
19+
return m.header
20+
}
21+
22+
func (m *MockResponseWriter) Write(bytes []byte) (int, error) {
23+
if m.StatusCode == 0 {
24+
m.StatusCode = http.StatusOK
25+
}
26+
return m.BodyBuffer.Write(bytes)
27+
}
28+
29+
func (m *MockResponseWriter) WriteHeader(statusCode int) {
30+
m.StatusCode = statusCode
31+
}
32+
33+
func NewMockResponseWriter() *MockResponseWriter {
34+
return &MockResponseWriter{header: http.Header{}}
35+
}
File renamed without changes.

modules/httplib/serve.go

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

0 commit comments

Comments
 (0)