From 023a048f52b5bf8c4b715285245a129f04e05a8c Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Tue, 9 May 2023 15:34:36 +0800
Subject: [PATCH] Make repository response support HTTP range request (#24592)

Replace #20480
Replace #18448

Close #16414
---
 modules/context/context_serve.go           |  61 +-----
 modules/context/context_test.go            |  24 +--
 modules/httplib/mock.go                    |  35 ++++
 modules/httplib/{httplib.go => request.go} |   0
 modules/httplib/serve.go                   | 225 +++++++++++++++++++++
 modules/httplib/serve_test.go              | 109 ++++++++++
 modules/lfs/content_store.go               |   6 +-
 routers/api/v1/repo/file.go                |  19 +-
 routers/common/repo.go                     | 116 -----------
 routers/common/serve.go                    |  43 ++++
 routers/web/repo/attachment.go             |   5 +-
 routers/web/repo/download.go               |   3 +-
 12 files changed, 434 insertions(+), 212 deletions(-)
 create mode 100644 modules/httplib/mock.go
 rename modules/httplib/{httplib.go => request.go} (100%)
 create mode 100644 modules/httplib/serve.go
 create mode 100644 modules/httplib/serve_test.go
 delete mode 100644 routers/common/repo.go
 create mode 100644 routers/common/serve.go

diff --git a/modules/context/context_serve.go b/modules/context/context_serve.go
index 44dd739eff..5569efbc7e 100644
--- a/modules/context/context_serve.go
+++ b/modules/context/context_serve.go
@@ -4,71 +4,20 @@
 package context
 
 import (
-	"fmt"
 	"io"
 	"net/http"
-	"net/url"
-	"strconv"
-	"strings"
-	"time"
 
-	"code.gitea.io/gitea/modules/httpcache"
-	"code.gitea.io/gitea/modules/typesniffer"
+	"code.gitea.io/gitea/modules/httplib"
 )
 
-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
-}
+type ServeHeaderOptions httplib.ServeHeaderOptions
 
-// SetServeHeaders sets necessary content serve headers
-func (ctx *Context) SetServeHeaders(opts *ServeHeaderOptions) {
-	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))
-	}
+func (ctx *Context) SetServeHeaders(opt *ServeHeaderOptions) {
+	httplib.ServeSetHeaders(ctx.Resp, (*httplib.ServeHeaderOptions)(opt))
 }
 
 // ServeContent serves content to http request
 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)
 }
diff --git a/modules/context/context_test.go b/modules/context/context_test.go
index e1460c1fd7..a6facc9788 100644
--- a/modules/context/context_test.go
+++ b/modules/context/context_test.go
@@ -7,32 +7,16 @@ import (
 	"net/http"
 	"testing"
 
+	"code.gitea.io/gitea/modules/httplib"
 	"code.gitea.io/gitea/modules/setting"
 
 	"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) {
-	w := &mockResponseWriter{}
-	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: "other", Value: "bar"}).String())
+	w := httplib.NewMockResponseWriter()
+	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())
 	assert.Len(t, w.Header().Values("Set-Cookie"), 2)
 	removeSessionCookieHeader(w)
 	assert.Len(t, w.Header().Values("Set-Cookie"), 1)
diff --git a/modules/httplib/mock.go b/modules/httplib/mock.go
new file mode 100644
index 0000000000..7d284e86fb
--- /dev/null
+++ b/modules/httplib/mock.go
@@ -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{}}
+}
diff --git a/modules/httplib/httplib.go b/modules/httplib/request.go
similarity index 100%
rename from modules/httplib/httplib.go
rename to modules/httplib/request.go
diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go
new file mode 100644
index 0000000000..12d68c2d65
--- /dev/null
+++ b/modules/httplib/serve.go
@@ -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)
+}
diff --git a/modules/httplib/serve_test.go b/modules/httplib/serve_test.go
new file mode 100644
index 0000000000..0768f1c713
--- /dev/null
+++ b/modules/httplib/serve_test.go
@@ -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:])
+	})
+}
diff --git a/modules/lfs/content_store.go b/modules/lfs/content_store.go
index 53fac4ab85..daf8c6cfdd 100644
--- a/modules/lfs/content_store.go
+++ b/modules/lfs/content_store.go
@@ -18,9 +18,9 @@ import (
 
 var (
 	// 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 = errors.New("Content size does not match")
+	ErrSizeMismatch = errors.New("content size does not match")
 )
 
 // 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
-func ReadMetaObject(pointer Pointer) (io.ReadCloser, error) {
+func ReadMetaObject(pointer Pointer) (io.ReadSeekCloser, error) {
 	contentStore := NewContentStore()
 	return contentStore.Get(pointer)
 }
diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go
index 5f7ed255bc..eb63dda590 100644
--- a/routers/api/v1/repo/file.go
+++ b/routers/api/v1/repo/file.go
@@ -150,6 +150,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
 		return
 	}
 
+	// FIXME: code from #19689, what if the file is large ... OOM ...
 	buf, err := io.ReadAll(dataRc)
 	if err != nil {
 		_ = dataRc.Close()
@@ -164,7 +165,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
 	// Check if the blob represents a pointer
 	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() {
 		// First handle caching for the blob
 		if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
@@ -172,25 +173,21 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
 		}
 
 		// OK not cached - serve!
-		if err := common.ServeData(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)); err != nil {
-			ctx.ServerError("ServeBlob", err)
-		}
+		common.ServeContentByReader(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf))
 		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)
 
-	// 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 {
 		// Handle caching for the blob SHA (not the LFS object OID)
 		if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) {
 			return
 		}
 
-		if err := common.ServeData(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)); err != nil {
-			ctx.ServerError("ServeBlob", err)
-		}
+		common.ServeContentByReader(ctx.Context, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf))
 		return
 	} else if err != nil {
 		ctx.ServerError("GetLFSMetaObjectByOid", err)
@@ -218,9 +215,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
 	}
 	defer lfsDataRc.Close()
 
-	if err := common.ServeData(ctx.Context, ctx.Repo.TreePath, meta.Size, lfsDataRc); err != nil {
-		ctx.ServerError("ServeData", err)
-	}
+	common.ServeContentByReadSeeker(ctx.Context, ctx.Repo.TreePath, lastModified, lfsDataRc)
 }
 
 func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified time.Time) {
diff --git a/routers/common/repo.go b/routers/common/repo.go
deleted file mode 100644
index 4b04ddae34..0000000000
--- a/routers/common/repo.go
+++ /dev/null
@@ -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
-}
diff --git a/routers/common/serve.go b/routers/common/serve.go
new file mode 100644
index 0000000000..59b993328e
--- /dev/null
+++ b/routers/common/serve.go
@@ -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)
+}
diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go
index c6ea4e3cdb..c46ec29841 100644
--- a/routers/web/repo/attachment.go
+++ b/routers/web/repo/attachment.go
@@ -153,10 +153,7 @@ func ServeAttachment(ctx *context.Context, uuid string) {
 	}
 	defer fr.Close()
 
-	if err = common.ServeData(ctx, attach.Name, attach.Size, fr); err != nil {
-		ctx.ServerError("ServeData", err)
-		return
-	}
+	common.ServeContentByReadSeeker(ctx, attach.Name, attach.CreatedUnix.AsTime(), fr)
 }
 
 // GetAttachment serve attachments
diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go
index 9e95f9dd0c..1c87f9bed7 100644
--- a/routers/web/repo/download.go
+++ b/routers/web/repo/download.go
@@ -71,7 +71,8 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified time.Time
 				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 {
 		log.Error("ServeBlobOrLFS: Close: %v", err)