mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Make repository response support HTTP range request (#24592)
Replace #20480 Replace #18448 Close #16414
This commit is contained in:
		| @@ -4,71 +4,20 @@ | |||||||
| package context | package context | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"fmt" |  | ||||||
| 	"io" | 	"io" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"net/url" |  | ||||||
| 	"strconv" |  | ||||||
| 	"strings" |  | ||||||
| 	"time" |  | ||||||
|  |  | ||||||
| 	"code.gitea.io/gitea/modules/httpcache" | 	"code.gitea.io/gitea/modules/httplib" | ||||||
| 	"code.gitea.io/gitea/modules/typesniffer" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type ServeHeaderOptions struct { | type ServeHeaderOptions httplib.ServeHeaderOptions | ||||||
| 	ContentType        string // defaults to "application/octet-stream" |  | ||||||
| 	ContentTypeCharset string |  | ||||||
| 	ContentLength      *int64 |  | ||||||
| 	Disposition        string // defaults to "attachment" |  | ||||||
| 	Filename           string |  | ||||||
| 	CacheDuration      time.Duration // defaults to 5 minutes |  | ||||||
| 	LastModified       time.Time |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SetServeHeaders sets necessary content serve headers | func (ctx *Context) SetServeHeaders(opt *ServeHeaderOptions) { | ||||||
| func (ctx *Context) SetServeHeaders(opts *ServeHeaderOptions) { | 	httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opt)) | ||||||
| 	header := ctx.Resp.Header() |  | ||||||
|  |  | ||||||
| 	contentType := typesniffer.ApplicationOctetStream |  | ||||||
| 	if opts.ContentType != "" { |  | ||||||
| 		if opts.ContentTypeCharset != "" { |  | ||||||
| 			contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset) |  | ||||||
| 		} else { |  | ||||||
| 			contentType = opts.ContentType |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	header.Set("Content-Type", contentType) |  | ||||||
| 	header.Set("X-Content-Type-Options", "nosniff") |  | ||||||
|  |  | ||||||
| 	if opts.ContentLength != nil { |  | ||||||
| 		header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if opts.Filename != "" { |  | ||||||
| 		disposition := opts.Disposition |  | ||||||
| 		if disposition == "" { |  | ||||||
| 			disposition = "attachment" |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		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))) |  | ||||||
| 		header.Set("Access-Control-Expose-Headers", "Content-Disposition") |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	duration := opts.CacheDuration |  | ||||||
| 	if duration == 0 { |  | ||||||
| 		duration = 5 * time.Minute |  | ||||||
| 	} |  | ||||||
| 	httpcache.SetCacheControlInHeader(header, duration) |  | ||||||
|  |  | ||||||
| 	if !opts.LastModified.IsZero() { |  | ||||||
| 		header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat)) |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ServeContent serves content to http request | // ServeContent serves content to http request | ||||||
| func (ctx *Context) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) { | func (ctx *Context) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) { | ||||||
| 	ctx.SetServeHeaders(opts) | 	httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opts)) | ||||||
| 	http.ServeContent(ctx.Resp, ctx.Req, opts.Filename, opts.LastModified, r) | 	http.ServeContent(ctx.Resp, ctx.Req, opts.Filename, opts.LastModified, r) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -7,32 +7,16 @@ import ( | |||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/httplib" | ||||||
| 	"code.gitea.io/gitea/modules/setting" | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type mockResponseWriter struct { |  | ||||||
| 	header http.Header |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (m *mockResponseWriter) Header() http.Header { |  | ||||||
| 	return m.header |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (m *mockResponseWriter) Write(bytes []byte) (int, error) { |  | ||||||
| 	panic("implement me") |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func (m *mockResponseWriter) WriteHeader(statusCode int) { |  | ||||||
| 	panic("implement me") |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestRemoveSessionCookieHeader(t *testing.T) { | func TestRemoveSessionCookieHeader(t *testing.T) { | ||||||
| 	w := &mockResponseWriter{} | 	w := httplib.NewMockResponseWriter() | ||||||
| 	w.header = http.Header{} | 	w.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String()) | ||||||
| 	w.header.Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String()) | 	w.Header().Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String()) | ||||||
| 	w.header.Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String()) |  | ||||||
| 	assert.Len(t, w.Header().Values("Set-Cookie"), 2) | 	assert.Len(t, w.Header().Values("Set-Cookie"), 2) | ||||||
| 	removeSessionCookieHeader(w) | 	removeSessionCookieHeader(w) | ||||||
| 	assert.Len(t, w.Header().Values("Set-Cookie"), 1) | 	assert.Len(t, w.Header().Values("Set-Cookie"), 1) | ||||||
|   | |||||||
							
								
								
									
										35
									
								
								modules/httplib/mock.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								modules/httplib/mock.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | // Copyright 2023 The Gitea Authors. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package httplib | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"net/http" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type MockResponseWriter struct { | ||||||
|  | 	header http.Header | ||||||
|  |  | ||||||
|  | 	StatusCode int | ||||||
|  | 	BodyBuffer bytes.Buffer | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (m *MockResponseWriter) Header() http.Header { | ||||||
|  | 	return m.header | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (m *MockResponseWriter) Write(bytes []byte) (int, error) { | ||||||
|  | 	if m.StatusCode == 0 { | ||||||
|  | 		m.StatusCode = http.StatusOK | ||||||
|  | 	} | ||||||
|  | 	return m.BodyBuffer.Write(bytes) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (m *MockResponseWriter) WriteHeader(statusCode int) { | ||||||
|  | 	m.StatusCode = statusCode | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewMockResponseWriter() *MockResponseWriter { | ||||||
|  | 	return &MockResponseWriter{header: http.Header{}} | ||||||
|  | } | ||||||
							
								
								
									
										225
									
								
								modules/httplib/serve.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								modules/httplib/serve.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,225 @@ | |||||||
|  | // Copyright 2023 The Gitea Authors. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package httplib | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"path" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"strconv" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	charsetModule "code.gitea.io/gitea/modules/charset" | ||||||
|  | 	"code.gitea.io/gitea/modules/httpcache" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
|  | 	"code.gitea.io/gitea/modules/typesniffer" | ||||||
|  | 	"code.gitea.io/gitea/modules/util" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type ServeHeaderOptions struct { | ||||||
|  | 	ContentType        string // defaults to "application/octet-stream" | ||||||
|  | 	ContentTypeCharset string | ||||||
|  | 	ContentLength      *int64 | ||||||
|  | 	Disposition        string // defaults to "attachment" | ||||||
|  | 	Filename           string | ||||||
|  | 	CacheDuration      time.Duration // defaults to 5 minutes | ||||||
|  | 	LastModified       time.Time | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ServeSetHeaders sets necessary content serve headers | ||||||
|  | func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { | ||||||
|  | 	header := w.Header() | ||||||
|  |  | ||||||
|  | 	contentType := typesniffer.ApplicationOctetStream | ||||||
|  | 	if opts.ContentType != "" { | ||||||
|  | 		if opts.ContentTypeCharset != "" { | ||||||
|  | 			contentType = opts.ContentType + "; charset=" + strings.ToLower(opts.ContentTypeCharset) | ||||||
|  | 		} else { | ||||||
|  | 			contentType = opts.ContentType | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	header.Set("Content-Type", contentType) | ||||||
|  | 	header.Set("X-Content-Type-Options", "nosniff") | ||||||
|  |  | ||||||
|  | 	if opts.ContentLength != nil { | ||||||
|  | 		header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if opts.Filename != "" { | ||||||
|  | 		disposition := opts.Disposition | ||||||
|  | 		if disposition == "" { | ||||||
|  | 			disposition = "attachment" | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		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))) | ||||||
|  | 		header.Set("Access-Control-Expose-Headers", "Content-Disposition") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	duration := opts.CacheDuration | ||||||
|  | 	if duration == 0 { | ||||||
|  | 		duration = 5 * time.Minute | ||||||
|  | 	} | ||||||
|  | 	httpcache.SetCacheControlInHeader(header, duration) | ||||||
|  |  | ||||||
|  | 	if !opts.LastModified.IsZero() { | ||||||
|  | 		header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat)) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ServeData download file from io.Reader | ||||||
|  | func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, filePath string, mineBuf []byte) { | ||||||
|  | 	// do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests | ||||||
|  | 	opts := &ServeHeaderOptions{ | ||||||
|  | 		Filename: path.Base(filePath), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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") != "" | ||||||
|  |  | ||||||
|  | 	if setting.MimeTypeMap.Enabled { | ||||||
|  | 		fileExtension := strings.ToLower(filepath.Ext(filePath)) | ||||||
|  | 		opts.ContentType = setting.MimeTypeMap.Map[fileExtension] | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if opts.ContentType == "" { | ||||||
|  | 		if sniffedType.IsBrowsableBinaryType() { | ||||||
|  | 			opts.ContentType = sniffedType.GetMimeType() | ||||||
|  | 		} else if isPlain { | ||||||
|  | 			opts.ContentType = "text/plain" | ||||||
|  | 		} else { | ||||||
|  | 			opts.ContentType = typesniffer.ApplicationOctetStream | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if isPlain { | ||||||
|  | 		charset, err := charsetModule.DetectEncoding(mineBuf) | ||||||
|  | 		if err != nil { | ||||||
|  | 			log.Error("Detect raw file %s charset failed: %v, using by default utf-8", filePath, err) | ||||||
|  | 			charset = "utf-8" | ||||||
|  | 		} | ||||||
|  | 		opts.ContentTypeCharset = strings.ToLower(charset) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	isSVG := sniffedType.IsSvgImage() | ||||||
|  |  | ||||||
|  | 	// 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") | ||||||
|  | 	} else 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 | ||||||
|  | 		w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	opts.Disposition = "inline" | ||||||
|  | 	if isSVG && !setting.UI.SVG.Enabled { | ||||||
|  | 		opts.Disposition = "attachment" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ServeSetHeaders(w, opts) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const mimeDetectionBufferLen = 1024 | ||||||
|  |  | ||||||
|  | func ServeContentByReader(r *http.Request, w http.ResponseWriter, filePath string, size int64, reader io.Reader) { | ||||||
|  | 	buf := make([]byte, mimeDetectionBufferLen) | ||||||
|  | 	n, err := util.ReadAtMost(reader, buf) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "serve content: unable to pre-read", http.StatusRequestedRangeNotSatisfiable) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if n >= 0 { | ||||||
|  | 		buf = buf[:n] | ||||||
|  | 	} | ||||||
|  | 	setServeHeadersByFile(r, w, filePath, buf) | ||||||
|  |  | ||||||
|  | 	// reset the reader to the beginning | ||||||
|  | 	reader = io.MultiReader(bytes.NewReader(buf), reader) | ||||||
|  |  | ||||||
|  | 	rangeHeader := r.Header.Get("Range") | ||||||
|  |  | ||||||
|  | 	// if no size or no supported range, serve as 200 (complete response) | ||||||
|  | 	if size <= 0 || !strings.HasPrefix(rangeHeader, "bytes=") { | ||||||
|  | 		if size >= 0 { | ||||||
|  | 			w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) | ||||||
|  | 		} | ||||||
|  | 		_, _ = io.Copy(w, reader) // just like http.ServeContent, not necessary to handle the error | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// do our best to support the minimal "Range" request (no support for multiple range: "Range: bytes=0-50, 100-150") | ||||||
|  | 	// | ||||||
|  | 	// GET /... | ||||||
|  | 	// Range: bytes=0-1023 | ||||||
|  | 	// | ||||||
|  | 	// HTTP/1.1 206 Partial Content | ||||||
|  | 	// Content-Range: bytes 0-1023/146515 | ||||||
|  | 	// Content-Length: 1024 | ||||||
|  |  | ||||||
|  | 	_, rangeParts, _ := strings.Cut(rangeHeader, "=") | ||||||
|  | 	rangeBytesStart, rangeBytesEnd, found := strings.Cut(rangeParts, "-") | ||||||
|  | 	start, err := strconv.ParseInt(rangeBytesStart, 10, 64) | ||||||
|  | 	if start < 0 || start >= size { | ||||||
|  | 		err = errors.New("invalid start range") | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	end, err := strconv.ParseInt(rangeBytesEnd, 10, 64) | ||||||
|  | 	if rangeBytesEnd == "" && found { | ||||||
|  | 		err = nil | ||||||
|  | 		end = size - 1 | ||||||
|  | 	} | ||||||
|  | 	if end >= size { | ||||||
|  | 		end = size - 1 | ||||||
|  | 	} | ||||||
|  | 	if end < start { | ||||||
|  | 		err = errors.New("invalid end range") | ||||||
|  | 	} | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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, filePath string, modTime time.Time, reader io.ReadSeeker) { | ||||||
|  | 	buf := make([]byte, mimeDetectionBufferLen) | ||||||
|  | 	n, err := util.ReadAtMost(reader, buf) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "serve content: unable to read", 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, filePath, buf) | ||||||
|  | 	http.ServeContent(w, r, path.Base(filePath), modTime, reader) | ||||||
|  | } | ||||||
							
								
								
									
										109
									
								
								modules/httplib/serve_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								modules/httplib/serve_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | |||||||
|  | // Copyright 2023 The Gitea Authors. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package httplib | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"os" | ||||||
|  | 	"strings" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestServeContentByReader(t *testing.T) { | ||||||
|  | 	data := "0123456789abcdef" | ||||||
|  |  | ||||||
|  | 	test := func(t *testing.T, expectedStatusCode int, expectedContent string) { | ||||||
|  | 		_, rangeStr, _ := strings.Cut(t.Name(), "_range_") | ||||||
|  | 		r := &http.Request{Header: http.Header{}, Form: url.Values{}} | ||||||
|  | 		if rangeStr != "" { | ||||||
|  | 			r.Header.Set("Range", fmt.Sprintf("bytes=%s", rangeStr)) | ||||||
|  | 		} | ||||||
|  | 		reader := strings.NewReader(data) | ||||||
|  | 		w := NewMockResponseWriter() | ||||||
|  | 		ServeContentByReader(r, w, "test", int64(len(data)), reader) | ||||||
|  | 		assert.Equal(t, expectedStatusCode, w.StatusCode) | ||||||
|  | 		if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { | ||||||
|  | 			assert.Equal(t, fmt.Sprint(len(expectedContent)), w.Header().Get("Content-Length")) | ||||||
|  | 			assert.Equal(t, expectedContent, w.BodyBuffer.String()) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	t.Run("_range_", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusOK, data) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_0-", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_0-15", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_1-", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data[1:]) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_1-3", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data[1:3+1]) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_16-", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusRequestedRangeNotSatisfiable, "") | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_1-99999", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data[1:]) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestServeContentByReadSeeker(t *testing.T) { | ||||||
|  | 	data := "0123456789abcdef" | ||||||
|  | 	tmpFile := t.TempDir() + "/test" | ||||||
|  | 	err := os.WriteFile(tmpFile, []byte(data), 0o644) | ||||||
|  | 	assert.NoError(t, err) | ||||||
|  |  | ||||||
|  | 	test := func(t *testing.T, expectedStatusCode int, expectedContent string) { | ||||||
|  | 		_, rangeStr, _ := strings.Cut(t.Name(), "_range_") | ||||||
|  | 		r := &http.Request{Header: http.Header{}, Form: url.Values{}} | ||||||
|  | 		if rangeStr != "" { | ||||||
|  | 			r.Header.Set("Range", fmt.Sprintf("bytes=%s", rangeStr)) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		seekReader, err := os.OpenFile(tmpFile, os.O_RDONLY, 0o644) | ||||||
|  | 		if !assert.NoError(t, err) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		defer seekReader.Close() | ||||||
|  |  | ||||||
|  | 		w := NewMockResponseWriter() | ||||||
|  | 		ServeContentByReadSeeker(r, w, "test", time.Time{}, seekReader) | ||||||
|  | 		assert.Equal(t, expectedStatusCode, w.StatusCode) | ||||||
|  | 		if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK { | ||||||
|  | 			assert.Equal(t, fmt.Sprint(len(expectedContent)), w.Header().Get("Content-Length")) | ||||||
|  | 			assert.Equal(t, expectedContent, w.BodyBuffer.String()) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	t.Run("_range_", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusOK, data) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_0-", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_0-15", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_1-", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data[1:]) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_1-3", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data[1:3+1]) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_16-", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusRequestedRangeNotSatisfiable, "") | ||||||
|  | 	}) | ||||||
|  | 	t.Run("_range_1-99999", func(t *testing.T) { | ||||||
|  | 		test(t, http.StatusPartialContent, data[1:]) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
| @@ -18,9 +18,9 @@ import ( | |||||||
|  |  | ||||||
| var ( | var ( | ||||||
| 	// ErrHashMismatch occurs if the content has does not match OID | 	// ErrHashMismatch occurs if the content has does not match OID | ||||||
| 	ErrHashMismatch = errors.New("Content hash does not match OID") | 	ErrHashMismatch = errors.New("content hash does not match OID") | ||||||
| 	// ErrSizeMismatch occurs if the content size does not match | 	// ErrSizeMismatch occurs if the content size does not match | ||||||
| 	ErrSizeMismatch = errors.New("Content size does not match") | 	ErrSizeMismatch = errors.New("content size does not match") | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // ContentStore provides a simple file system based storage. | // ContentStore provides a simple file system based storage. | ||||||
| @@ -105,7 +105,7 @@ func (s *ContentStore) Verify(pointer Pointer) (bool, error) { | |||||||
| } | } | ||||||
|  |  | ||||||
| // ReadMetaObject will read a git_model.LFSMetaObject and return a reader | // ReadMetaObject will read a git_model.LFSMetaObject and return a reader | ||||||
| func ReadMetaObject(pointer Pointer) (io.ReadCloser, error) { | func ReadMetaObject(pointer Pointer) (io.ReadSeekCloser, error) { | ||||||
| 	contentStore := NewContentStore() | 	contentStore := NewContentStore() | ||||||
| 	return contentStore.Get(pointer) | 	return contentStore.Get(pointer) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -150,6 +150,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// FIXME: code from #19689, what if the file is large ... OOM ... | ||||||
| 	buf, err := io.ReadAll(dataRc) | 	buf, err := io.ReadAll(dataRc) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		_ = dataRc.Close() | 		_ = dataRc.Close() | ||||||
| @@ -164,7 +165,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { | |||||||
| 	// Check if the blob represents a pointer | 	// Check if the blob represents a pointer | ||||||
| 	pointer, _ := lfs.ReadPointer(bytes.NewReader(buf)) | 	pointer, _ := lfs.ReadPointer(bytes.NewReader(buf)) | ||||||
|  |  | ||||||
| 	// if its not a pointer just serve the data directly | 	// if it's not a pointer, just serve the data directly | ||||||
| 	if !pointer.IsValid() { | 	if !pointer.IsValid() { | ||||||
| 		// First handle caching for the blob | 		// First handle caching for the blob | ||||||
| 		if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { | 		if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { | ||||||
| @@ -172,25 +173,21 @@ func GetRawFileOrLFS(ctx *context.APIContext) { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// OK not cached - serve! | 		// OK not cached - serve! | ||||||
| 		if err := common.ServeData(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)); err != nil { | 		common.ServeContentByReader(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) | ||||||
| 			ctx.ServerError("ServeBlob", err) |  | ||||||
| 		} |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Now check if there is a meta object for this pointer | 	// Now check if there is a MetaObject for this pointer | ||||||
| 	meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid) | 	meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid) | ||||||
|  |  | ||||||
| 	// If there isn't one just serve the data directly | 	// If there isn't one, just serve the data directly | ||||||
| 	if err == git_model.ErrLFSObjectNotExist { | 	if err == git_model.ErrLFSObjectNotExist { | ||||||
| 		// Handle caching for the blob SHA (not the LFS object OID) | 		// Handle caching for the blob SHA (not the LFS object OID) | ||||||
| 		if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { | 		if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if err := common.ServeData(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)); err != nil { | 		common.ServeContentByReader(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) | ||||||
| 			ctx.ServerError("ServeBlob", err) |  | ||||||
| 		} |  | ||||||
| 		return | 		return | ||||||
| 	} else if err != nil { | 	} else if err != nil { | ||||||
| 		ctx.ServerError("GetLFSMetaObjectByOid", err) | 		ctx.ServerError("GetLFSMetaObjectByOid", err) | ||||||
| @@ -218,9 +215,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { | |||||||
| 	} | 	} | ||||||
| 	defer lfsDataRc.Close() | 	defer lfsDataRc.Close() | ||||||
|  |  | ||||||
| 	if err := common.ServeData(ctx.Context, ctx.Repo.TreePath, meta.Size, lfsDataRc); err != nil { | 	common.ServeContentByReadSeeker(ctx.Context, ctx.Repo.TreePath, lastModified, lfsDataRc) | ||||||
| 		ctx.ServerError("ServeData", err) |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  |  | ||||||
| func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified time.Time) { | func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified time.Time) { | ||||||
|   | |||||||
| @@ -1,116 +0,0 @@ | |||||||
| // Copyright 2021 The Gitea Authors. All rights reserved. |  | ||||||
| // SPDX-License-Identifier: MIT |  | ||||||
|  |  | ||||||
| package common |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"io" |  | ||||||
| 	"path" |  | ||||||
| 	"path/filepath" |  | ||||||
| 	"strings" |  | ||||||
| 	"time" |  | ||||||
|  |  | ||||||
| 	charsetModule "code.gitea.io/gitea/modules/charset" |  | ||||||
| 	"code.gitea.io/gitea/modules/context" |  | ||||||
| 	"code.gitea.io/gitea/modules/git" |  | ||||||
| 	"code.gitea.io/gitea/modules/httpcache" |  | ||||||
| 	"code.gitea.io/gitea/modules/log" |  | ||||||
| 	"code.gitea.io/gitea/modules/setting" |  | ||||||
| 	"code.gitea.io/gitea/modules/typesniffer" |  | ||||||
| 	"code.gitea.io/gitea/modules/util" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| // ServeBlob download a git.Blob |  | ||||||
| func ServeBlob(ctx *context.Context, blob *git.Blob, lastModified time.Time) error { |  | ||||||
| 	if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	dataRc, err := blob.DataAsync() |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	defer func() { |  | ||||||
| 		if err = dataRc.Close(); err != nil { |  | ||||||
| 			log.Error("ServeBlob: Close: %v", err) |  | ||||||
| 		} |  | ||||||
| 	}() |  | ||||||
|  |  | ||||||
| 	return ServeData(ctx, ctx.Repo.TreePath, blob.Size(), dataRc) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // ServeData download file from io.Reader |  | ||||||
| func ServeData(ctx *context.Context, filePath string, size int64, reader io.Reader) error { |  | ||||||
| 	buf := make([]byte, 1024) |  | ||||||
| 	n, err := util.ReadAtMost(reader, buf) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if n >= 0 { |  | ||||||
| 		buf = buf[:n] |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	opts := &context.ServeHeaderOptions{ |  | ||||||
| 		Filename: path.Base(filePath), |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if size >= 0 { |  | ||||||
| 		opts.ContentLength = &size |  | ||||||
| 	} else { |  | ||||||
| 		log.Error("ServeData called to serve data: %s with size < 0: %d", filePath, size) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	sniffedType := typesniffer.DetectContentType(buf) |  | ||||||
| 	isPlain := sniffedType.IsText() || ctx.FormBool("render") |  | ||||||
|  |  | ||||||
| 	if setting.MimeTypeMap.Enabled { |  | ||||||
| 		fileExtension := strings.ToLower(filepath.Ext(filePath)) |  | ||||||
| 		opts.ContentType = setting.MimeTypeMap.Map[fileExtension] |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if opts.ContentType == "" { |  | ||||||
| 		if sniffedType.IsBrowsableBinaryType() { |  | ||||||
| 			opts.ContentType = sniffedType.GetMimeType() |  | ||||||
| 		} else if isPlain { |  | ||||||
| 			opts.ContentType = "text/plain" |  | ||||||
| 		} else { |  | ||||||
| 			opts.ContentType = typesniffer.ApplicationOctetStream |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if isPlain { |  | ||||||
| 		var charset string |  | ||||||
| 		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" |  | ||||||
| 		} |  | ||||||
| 		opts.ContentTypeCharset = strings.ToLower(charset) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	isSVG := sniffedType.IsSvgImage() |  | ||||||
|  |  | ||||||
| 	// serve types that can present a security risk with CSP |  | ||||||
| 	if isSVG { |  | ||||||
| 		ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") |  | ||||||
| 	} else 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 |  | ||||||
| 		ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	opts.Disposition = "inline" |  | ||||||
| 	if isSVG && !setting.UI.SVG.Enabled { |  | ||||||
| 		opts.Disposition = "attachment" |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	ctx.SetServeHeaders(opts) |  | ||||||
|  |  | ||||||
| 	_, err = ctx.Resp.Write(buf) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	_, err = io.Copy(ctx.Resp, reader) |  | ||||||
| 	return err |  | ||||||
| } |  | ||||||
							
								
								
									
										43
									
								
								routers/common/serve.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								routers/common/serve.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | // Copyright 2021 The Gitea Authors. All rights reserved. | ||||||
|  | // SPDX-License-Identifier: MIT | ||||||
|  |  | ||||||
|  | package common | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"io" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | 	"code.gitea.io/gitea/modules/git" | ||||||
|  | 	"code.gitea.io/gitea/modules/httpcache" | ||||||
|  | 	"code.gitea.io/gitea/modules/httplib" | ||||||
|  | 	"code.gitea.io/gitea/modules/log" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // ServeBlob download a git.Blob | ||||||
|  | func ServeBlob(ctx *context.Context, blob *git.Blob, lastModified time.Time) error { | ||||||
|  | 	if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	dataRc, err := blob.DataAsync() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	defer func() { | ||||||
|  | 		if err = dataRc.Close(); err != nil { | ||||||
|  | 			log.Error("ServeBlob: Close: %v", err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  |  | ||||||
|  | 	httplib.ServeContentByReader(ctx.Req, ctx.Resp, ctx.Repo.TreePath, blob.Size(), dataRc) | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func ServeContentByReader(ctx *context.Context, filePath string, size int64, reader io.Reader) { | ||||||
|  | 	httplib.ServeContentByReader(ctx.Req, ctx.Resp, filePath, size, reader) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func ServeContentByReadSeeker(ctx *context.Context, filePath string, modTime time.Time, reader io.ReadSeeker) { | ||||||
|  | 	httplib.ServeContentByReadSeeker(ctx.Req, ctx.Resp, filePath, modTime, reader) | ||||||
|  | } | ||||||
| @@ -153,10 +153,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { | |||||||
| 	} | 	} | ||||||
| 	defer fr.Close() | 	defer fr.Close() | ||||||
|  |  | ||||||
| 	if err = common.ServeData(ctx, attach.Name, attach.Size, fr); err != nil { | 	common.ServeContentByReadSeeker(ctx, attach.Name, attach.CreatedUnix.AsTime(), fr) | ||||||
| 		ctx.ServerError("ServeData", err) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetAttachment serve attachments | // GetAttachment serve attachments | ||||||
|   | |||||||
| @@ -71,7 +71,8 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified time.Time | |||||||
| 				log.Error("ServeBlobOrLFS: Close: %v", err) | 				log.Error("ServeBlobOrLFS: Close: %v", err) | ||||||
| 			} | 			} | ||||||
| 		}() | 		}() | ||||||
| 		return common.ServeData(ctx, ctx.Repo.TreePath, meta.Size, lfsDataRc) | 		common.ServeContentByReadSeeker(ctx, ctx.Repo.TreePath, lastModified, lfsDataRc) | ||||||
|  | 		return nil | ||||||
| 	} | 	} | ||||||
| 	if err = dataRc.Close(); err != nil { | 	if err = dataRc.Close(); err != nil { | ||||||
| 		log.Error("ServeBlobOrLFS: Close: %v", err) | 		log.Error("ServeBlobOrLFS: Close: %v", err) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 wxiaoguang
					wxiaoguang