first-commit
This commit is contained in:
220
modules/httplib/request.go
Normal file
220
modules/httplib/request.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright 2013 The Beego Authors. All rights reserved.
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultSetting = Settings{"GiteaServer", 60 * time.Second, 60 * time.Second, nil, nil}
|
||||
|
||||
// newRequest returns *Request with specific method
|
||||
func newRequest(url, method string) *Request {
|
||||
var resp http.Response
|
||||
req := http.Request{
|
||||
Method: method,
|
||||
Header: make(http.Header),
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
}
|
||||
return &Request{url, &req, map[string]string{}, defaultSetting, &resp, nil}
|
||||
}
|
||||
|
||||
// NewRequest returns *Request with specific method
|
||||
func NewRequest(url, method string) *Request {
|
||||
return newRequest(url, method)
|
||||
}
|
||||
|
||||
// Settings is the default settings for http client
|
||||
type Settings struct {
|
||||
UserAgent string
|
||||
ConnectTimeout time.Duration
|
||||
ReadWriteTimeout time.Duration
|
||||
TLSClientConfig *tls.Config
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
// Request provides more useful methods for requesting one url than http.Request.
|
||||
type Request struct {
|
||||
url string
|
||||
req *http.Request
|
||||
params map[string]string
|
||||
setting Settings
|
||||
resp *http.Response
|
||||
body []byte
|
||||
}
|
||||
|
||||
// SetContext sets the request's Context
|
||||
func (r *Request) SetContext(ctx context.Context) *Request {
|
||||
r.req = r.req.WithContext(ctx)
|
||||
return r
|
||||
}
|
||||
|
||||
// SetTimeout sets connect time out and read-write time out for BeegoRequest.
|
||||
func (r *Request) SetTimeout(connectTimeout, readWriteTimeout time.Duration) *Request {
|
||||
r.setting.ConnectTimeout = connectTimeout
|
||||
r.setting.ReadWriteTimeout = readWriteTimeout
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Request) SetReadWriteTimeout(readWriteTimeout time.Duration) *Request {
|
||||
r.setting.ReadWriteTimeout = readWriteTimeout
|
||||
return r
|
||||
}
|
||||
|
||||
// SetTLSClientConfig sets tls connection configurations if visiting https url.
|
||||
func (r *Request) SetTLSClientConfig(config *tls.Config) *Request {
|
||||
r.setting.TLSClientConfig = config
|
||||
return r
|
||||
}
|
||||
|
||||
// Header add header item string in request.
|
||||
func (r *Request) Header(key, value string) *Request {
|
||||
r.req.Header.Set(key, value)
|
||||
return r
|
||||
}
|
||||
|
||||
// SetTransport sets transport to
|
||||
func (r *Request) SetTransport(transport http.RoundTripper) *Request {
|
||||
r.setting.Transport = transport
|
||||
return r
|
||||
}
|
||||
|
||||
// Param adds query param in to request.
|
||||
// params build query string as ?key1=value1&key2=value2...
|
||||
func (r *Request) Param(key, value string) *Request {
|
||||
r.params[key] = value
|
||||
return r
|
||||
}
|
||||
|
||||
// Body adds request raw body. It supports string, []byte and io.Reader as body.
|
||||
func (r *Request) Body(data any) *Request {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
switch t := data.(type) {
|
||||
case nil: // do nothing
|
||||
case string:
|
||||
bf := strings.NewReader(t)
|
||||
r.req.Body = io.NopCloser(bf)
|
||||
r.req.ContentLength = int64(len(t))
|
||||
case []byte:
|
||||
bf := bytes.NewBuffer(t)
|
||||
r.req.Body = io.NopCloser(bf)
|
||||
r.req.ContentLength = int64(len(t))
|
||||
case io.ReadCloser:
|
||||
r.req.Body = t
|
||||
case io.Reader:
|
||||
r.req.Body = io.NopCloser(t)
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported request body type %T", t))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Request) getResponse() (*http.Response, error) {
|
||||
if r.resp.StatusCode != 0 {
|
||||
return r.resp, nil
|
||||
}
|
||||
|
||||
var paramBody string
|
||||
if len(r.params) > 0 {
|
||||
var buf bytes.Buffer
|
||||
for k, v := range r.params {
|
||||
buf.WriteString(url.QueryEscape(k))
|
||||
buf.WriteByte('=')
|
||||
buf.WriteString(url.QueryEscape(v))
|
||||
buf.WriteByte('&')
|
||||
}
|
||||
paramBody = buf.String()
|
||||
paramBody = paramBody[0 : len(paramBody)-1]
|
||||
}
|
||||
|
||||
if r.req.Method == http.MethodGet && len(paramBody) > 0 {
|
||||
if strings.Contains(r.url, "?") {
|
||||
r.url += "&" + paramBody
|
||||
} else {
|
||||
r.url = r.url + "?" + paramBody
|
||||
}
|
||||
} else if r.req.Method == http.MethodPost && r.req.Body == nil && len(paramBody) > 0 {
|
||||
r.Header("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.Body(paramBody) // string
|
||||
}
|
||||
|
||||
var err error
|
||||
r.req.URL, err = url.Parse(r.url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trans := r.setting.Transport
|
||||
if trans == nil {
|
||||
// create default transport
|
||||
trans = &http.Transport{
|
||||
TLSClientConfig: r.setting.TLSClientConfig,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: TimeoutDialer(r.setting.ConnectTimeout),
|
||||
}
|
||||
} else if t, ok := trans.(*http.Transport); ok {
|
||||
if t.TLSClientConfig == nil {
|
||||
t.TLSClientConfig = r.setting.TLSClientConfig
|
||||
}
|
||||
if t.DialContext == nil {
|
||||
t.DialContext = TimeoutDialer(r.setting.ConnectTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: trans,
|
||||
Timeout: r.setting.ReadWriteTimeout,
|
||||
}
|
||||
|
||||
if len(r.setting.UserAgent) > 0 && len(r.req.Header.Get("User-Agent")) == 0 {
|
||||
r.req.Header.Set("User-Agent", r.setting.UserAgent)
|
||||
}
|
||||
|
||||
resp, err := client.Do(r.req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.resp = resp
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Response executes request client gets response manually.
|
||||
// Caller MUST close the response body if no error occurs
|
||||
func (r *Request) Response() (*http.Response, error) {
|
||||
if r == nil {
|
||||
return nil, errors.New("invalid request")
|
||||
}
|
||||
return r.getResponse()
|
||||
}
|
||||
|
||||
// TimeoutDialer returns functions of connection dialer with timeout settings for http.Transport Dial field.
|
||||
func TimeoutDialer(cTimeout time.Duration) func(ctx context.Context, net, addr string) (c net.Conn, err error) {
|
||||
return func(ctx context.Context, netw, addr string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: cTimeout}
|
||||
conn, err := d.DialContext(ctx, netw, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Request) GoString() string {
|
||||
return fmt.Sprintf("%s %s", r.req.Method, r.url)
|
||||
}
|
234
modules/httplib/serve.go
Normal file
234
modules/httplib/serve.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// 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/container"
|
||||
"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"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
)
|
||||
|
||||
type ServeHeaderOptions struct {
|
||||
ContentType string // defaults to "application/octet-stream"
|
||||
ContentTypeCharset string
|
||||
ContentLength *int64
|
||||
Disposition string // defaults to "attachment"
|
||||
Filename string
|
||||
CacheIsPublic bool
|
||||
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()
|
||||
|
||||
skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp")
|
||||
if skipCompressionExts.Contains(strings.ToLower(path.Ext(opts.Filename))) {
|
||||
w.Header().Add(gzhttp.HeaderNoCompression, "1")
|
||||
}
|
||||
|
||||
contentType := typesniffer.MimeTypeApplicationOctetStream
|
||||
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")
|
||||
}
|
||||
|
||||
httpcache.SetCacheControlInHeader(header, &httpcache.CacheControlOptions{
|
||||
IsPublic: opts.CacheIsPublic,
|
||||
MaxAge: opts.CacheDuration,
|
||||
NoTransform: true,
|
||||
})
|
||||
|
||||
if !opts.LastModified.IsZero() {
|
||||
// http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
|
||||
header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
}
|
||||
|
||||
// ServeData download file from io.Reader
|
||||
func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byte, opts *ServeHeaderOptions) {
|
||||
// do not set "Content-Length", because the length could only be set by callers, and it needs to support range requests
|
||||
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(opts.Filename))
|
||||
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.MimeTypeApplicationOctetStream
|
||||
}
|
||||
}
|
||||
|
||||
if isPlain {
|
||||
charset, err := charsetModule.DetectEncoding(mineBuf)
|
||||
if err != nil {
|
||||
log.Error("Detect raw file %s charset failed: %v, using by default utf-8", opts.Filename, 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, size int64, reader io.Reader, opts *ServeHeaderOptions) {
|
||||
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, buf, opts)
|
||||
|
||||
// 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, modTime *time.Time, reader io.ReadSeeker, opts *ServeHeaderOptions) {
|
||||
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, buf, opts)
|
||||
if modTime == nil {
|
||||
modTime = &time.Time{}
|
||||
}
|
||||
http.ServeContent(w, r, opts.Filename, *modTime, reader)
|
||||
}
|
108
modules/httplib/serve_test.go
Normal file
108
modules/httplib/serve_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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", "bytes="+rangeStr)
|
||||
}
|
||||
reader := strings.NewReader(data)
|
||||
w := httptest.NewRecorder()
|
||||
ServeContentByReader(r, w, int64(len(data)), reader, &ServeHeaderOptions{})
|
||||
assert.Equal(t, expectedStatusCode, w.Code)
|
||||
if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
|
||||
assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length"))
|
||||
assert.Equal(t, expectedContent, w.Body.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", "bytes="+rangeStr)
|
||||
}
|
||||
|
||||
seekReader, err := os.OpenFile(tmpFile, os.O_RDONLY, 0o644)
|
||||
require.NoError(t, err)
|
||||
defer seekReader.Close()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ServeContentByReadSeeker(r, w, nil, seekReader, &ServeHeaderOptions{})
|
||||
assert.Equal(t, expectedStatusCode, w.Code)
|
||||
if expectedStatusCode == http.StatusPartialContent || expectedStatusCode == http.StatusOK {
|
||||
assert.Equal(t, strconv.Itoa(len(expectedContent)), w.Header().Get("Content-Length"))
|
||||
assert.Equal(t, expectedContent, w.Body.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:])
|
||||
})
|
||||
}
|
184
modules/httplib/url.go
Normal file
184
modules/httplib/url.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
)
|
||||
|
||||
type RequestContextKeyStruct struct{}
|
||||
|
||||
var RequestContextKey = RequestContextKeyStruct{}
|
||||
|
||||
func urlIsRelative(s string, u *url.URL) bool {
|
||||
// Unfortunately browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
|
||||
// Therefore we should ignore these redirect locations to prevent open redirects
|
||||
if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
|
||||
return false
|
||||
}
|
||||
return u != nil && u.Scheme == "" && u.Host == ""
|
||||
}
|
||||
|
||||
// IsRelativeURL detects if a URL is relative (no scheme or host)
|
||||
func IsRelativeURL(s string) bool {
|
||||
u, err := url.Parse(s)
|
||||
return err == nil && urlIsRelative(s, u)
|
||||
}
|
||||
|
||||
func getRequestScheme(req *http.Request) string {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
|
||||
if s := req.Header.Get("X-Forwarded-Proto"); s != "" {
|
||||
return s
|
||||
}
|
||||
if s := req.Header.Get("X-Forwarded-Protocol"); s != "" {
|
||||
return s
|
||||
}
|
||||
if s := req.Header.Get("X-Url-Scheme"); s != "" {
|
||||
return s
|
||||
}
|
||||
if s := req.Header.Get("Front-End-Https"); s != "" {
|
||||
return util.Iif(s == "on", "https", "http")
|
||||
}
|
||||
if s := req.Header.Get("X-Forwarded-Ssl"); s != "" {
|
||||
return util.Iif(s == "on", "https", "http")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GuessCurrentAppURL tries to guess the current full public URL (with sub-path) by http headers. It always has a '/' suffix, exactly the same as setting.AppURL
|
||||
// TODO: should rename it to GuessCurrentPublicURL in the future
|
||||
func GuessCurrentAppURL(ctx context.Context) string {
|
||||
return GuessCurrentHostURL(ctx) + setting.AppSubURL + "/"
|
||||
}
|
||||
|
||||
// GuessCurrentHostURL tries to guess the current full host URL (no sub-path) by http headers, there is no trailing slash.
|
||||
func GuessCurrentHostURL(ctx context.Context) string {
|
||||
// Try the best guess to get the current host URL (will be used for public URL) by http headers.
|
||||
// At the moment, if site admin doesn't configure the proxy headers correctly, then Gitea would guess wrong.
|
||||
// There are some cases:
|
||||
// 1. The reverse proxy is configured correctly, it passes "X-Forwarded-Proto/Host" headers. Perfect, Gitea can handle it correctly.
|
||||
// 2. The reverse proxy is not configured correctly, doesn't pass "X-Forwarded-Proto/Host" headers, eg: only one "proxy_pass http://gitea:3000" in Nginx.
|
||||
// 3. There is no reverse proxy.
|
||||
// Without more information, Gitea is impossible to distinguish between case 2 and case 3, then case 2 would result in
|
||||
// wrong guess like guessed public URL becomes "http://gitea:3000/" behind a "https" reverse proxy, which is not accessible by end users.
|
||||
// So we introduced "PUBLIC_URL_DETECTION" option, to control the guessing behavior to satisfy different use cases.
|
||||
req, ok := ctx.Value(RequestContextKey).(*http.Request)
|
||||
if !ok {
|
||||
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
|
||||
}
|
||||
reqScheme := getRequestScheme(req)
|
||||
if reqScheme == "" {
|
||||
// if no reverse proxy header, try to use "Host" header for absolute URL
|
||||
if setting.PublicURLDetection == setting.PublicURLAuto && req.Host != "" {
|
||||
return util.Iif(req.TLS == nil, "http://", "https://") + req.Host
|
||||
}
|
||||
// fall back to default AppURL
|
||||
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
|
||||
}
|
||||
// X-Forwarded-Host has many problems: non-standard, not well-defined (X-Forwarded-Port or not), conflicts with Host header.
|
||||
// So do not use X-Forwarded-Host, just use Host header directly.
|
||||
return reqScheme + "://" + req.Host
|
||||
}
|
||||
|
||||
func GuessCurrentHostDomain(ctx context.Context) string {
|
||||
_, host, _ := strings.Cut(GuessCurrentHostURL(ctx), "://")
|
||||
domain, _, _ := net.SplitHostPort(host)
|
||||
return util.IfZero(domain, host)
|
||||
}
|
||||
|
||||
// MakeAbsoluteURL tries to make a link to an absolute public URL:
|
||||
// * If link is empty, it returns the current public URL.
|
||||
// * If link is absolute, it returns the link.
|
||||
// * Otherwise, it returns the current host URL + link, the link itself should have correct sub-path (AppSubURL) if needed.
|
||||
func MakeAbsoluteURL(ctx context.Context, link string) string {
|
||||
if link == "" {
|
||||
return GuessCurrentAppURL(ctx)
|
||||
}
|
||||
if !IsRelativeURL(link) {
|
||||
return link
|
||||
}
|
||||
return GuessCurrentHostURL(ctx) + "/" + strings.TrimPrefix(link, "/")
|
||||
}
|
||||
|
||||
type urlType int
|
||||
|
||||
const (
|
||||
urlTypeGiteaAbsolute urlType = iota + 1 // "http://gitea/subpath"
|
||||
urlTypeGiteaPageRelative // "/subpath"
|
||||
urlTypeGiteaSiteRelative // "?key=val"
|
||||
urlTypeUnknown // "http://other"
|
||||
)
|
||||
|
||||
func detectURLRoutePath(ctx context.Context, s string) (routePath string, ut urlType) {
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return "", urlTypeUnknown
|
||||
}
|
||||
cleanedPath := ""
|
||||
if u.Path != "" {
|
||||
cleanedPath = util.PathJoinRelX(u.Path)
|
||||
cleanedPath = util.Iif(cleanedPath == ".", "", "/"+cleanedPath)
|
||||
}
|
||||
if urlIsRelative(s, u) {
|
||||
if u.Path == "" {
|
||||
return "", urlTypeGiteaPageRelative
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(cleanedPath+"/"), strings.ToLower(setting.AppSubURL+"/")) {
|
||||
return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaSiteRelative
|
||||
}
|
||||
return "", urlTypeUnknown
|
||||
}
|
||||
u.Path = cleanedPath + "/"
|
||||
urlLower := strings.ToLower(u.String())
|
||||
if strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) {
|
||||
return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaAbsolute
|
||||
}
|
||||
guessedCurURL := GuessCurrentAppURL(ctx)
|
||||
if strings.HasPrefix(urlLower, strings.ToLower(guessedCurURL)) {
|
||||
return cleanedPath[len(setting.AppSubURL):], urlTypeGiteaAbsolute
|
||||
}
|
||||
return "", urlTypeUnknown
|
||||
}
|
||||
|
||||
func IsCurrentGiteaSiteURL(ctx context.Context, s string) bool {
|
||||
_, ut := detectURLRoutePath(ctx, s)
|
||||
return ut != urlTypeUnknown
|
||||
}
|
||||
|
||||
type GiteaSiteURL struct {
|
||||
RoutePath string
|
||||
OwnerName string
|
||||
RepoName string
|
||||
RepoSubPath string
|
||||
}
|
||||
|
||||
func ParseGiteaSiteURL(ctx context.Context, s string) *GiteaSiteURL {
|
||||
routePath, ut := detectURLRoutePath(ctx, s)
|
||||
if ut == urlTypeUnknown || ut == urlTypeGiteaPageRelative {
|
||||
return nil
|
||||
}
|
||||
ret := &GiteaSiteURL{RoutePath: routePath}
|
||||
fields := strings.SplitN(strings.TrimPrefix(ret.RoutePath, "/"), "/", 3)
|
||||
|
||||
// TODO: now it only does a quick check for some known reserved paths, should do more strict checks in the future
|
||||
if fields[0] == "attachments" {
|
||||
return ret
|
||||
}
|
||||
if len(fields) < 2 {
|
||||
return ret
|
||||
}
|
||||
ret.OwnerName = fields[0]
|
||||
ret.RepoName = fields[1]
|
||||
if len(fields) == 3 {
|
||||
ret.RepoSubPath = "/" + fields[2]
|
||||
}
|
||||
return ret
|
||||
}
|
184
modules/httplib/url_test.go
Normal file
184
modules/httplib/url_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsRelativeURL(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
rel := []string{
|
||||
"",
|
||||
"foo",
|
||||
"/",
|
||||
"/foo?k=%20#abc",
|
||||
}
|
||||
for _, s := range rel {
|
||||
assert.True(t, IsRelativeURL(s), "rel = %q", s)
|
||||
}
|
||||
abs := []string{
|
||||
"//",
|
||||
"\\\\",
|
||||
"/\\",
|
||||
"\\/",
|
||||
"mailto:a@b.com",
|
||||
"https://test.com",
|
||||
}
|
||||
for _, s := range abs {
|
||||
assert.False(t, IsRelativeURL(s), "abs = %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessCurrentHostURL(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://cfg-host/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
headersWithProto := http.Header{"X-Forwarded-Proto": {"https"}}
|
||||
|
||||
t.Run("Legacy", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLLegacy)()
|
||||
|
||||
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(t.Context()))
|
||||
|
||||
// legacy: "Host" is not used when there is no "X-Forwarded-Proto" header
|
||||
ctx := context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000"})
|
||||
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
|
||||
|
||||
// if "X-Forwarded-Proto" exists, then use it and "Host" header
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto})
|
||||
assert.Equal(t, "https://req-host:3000", GuessCurrentHostURL(ctx))
|
||||
})
|
||||
|
||||
t.Run("Auto", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLAuto)()
|
||||
|
||||
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(t.Context()))
|
||||
|
||||
// auto: always use "Host" header, the scheme is determined by "X-Forwarded-Proto" header, or TLS config if no "X-Forwarded-Proto" header
|
||||
ctx := context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000"})
|
||||
assert.Equal(t, "http://req-host:3000", GuessCurrentHostURL(ctx))
|
||||
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host", TLS: &tls.ConnectionState{}})
|
||||
assert.Equal(t, "https://req-host", GuessCurrentHostURL(ctx))
|
||||
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto})
|
||||
assert.Equal(t, "https://req-host:3000", GuessCurrentHostURL(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMakeAbsoluteURL(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Protocol, "http")()
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://cfg-host/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
|
||||
ctx := t.Context()
|
||||
assert.Equal(t, "http://cfg-host/sub/", MakeAbsoluteURL(ctx, ""))
|
||||
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "foo"))
|
||||
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "/foo"))
|
||||
assert.Equal(t, "http://other/foo", MakeAbsoluteURL(ctx, "http://other/foo"))
|
||||
|
||||
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
|
||||
Host: "user-host",
|
||||
})
|
||||
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "/foo"))
|
||||
|
||||
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
|
||||
Host: "user-host",
|
||||
Header: map[string][]string{
|
||||
"X-Forwarded-Host": {"forwarded-host"},
|
||||
},
|
||||
})
|
||||
assert.Equal(t, "http://cfg-host/foo", MakeAbsoluteURL(ctx, "/foo"))
|
||||
|
||||
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
|
||||
Host: "user-host",
|
||||
Header: map[string][]string{
|
||||
"X-Forwarded-Host": {"forwarded-host"},
|
||||
"X-Forwarded-Proto": {"https"},
|
||||
},
|
||||
})
|
||||
assert.Equal(t, "https://user-host/foo", MakeAbsoluteURL(ctx, "/foo"))
|
||||
}
|
||||
|
||||
func TestIsCurrentGiteaSiteURL(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
ctx := t.Context()
|
||||
good := []string{
|
||||
"?key=val",
|
||||
"/sub",
|
||||
"/sub/",
|
||||
"/sub/foo",
|
||||
"/sub/foo/",
|
||||
"http://localhost:3000/sub?key=val",
|
||||
"http://localhost:3000/sub/",
|
||||
}
|
||||
for _, s := range good {
|
||||
assert.True(t, IsCurrentGiteaSiteURL(ctx, s), "good = %q", s)
|
||||
}
|
||||
bad := []string{
|
||||
".",
|
||||
"foo",
|
||||
"/",
|
||||
"//",
|
||||
"\\\\",
|
||||
"/foo",
|
||||
"http://localhost:3000/sub/..",
|
||||
"http://localhost:3000/other",
|
||||
"http://other/",
|
||||
}
|
||||
for _, s := range bad {
|
||||
assert.False(t, IsCurrentGiteaSiteURL(ctx, s), "bad = %q", s)
|
||||
}
|
||||
|
||||
setting.AppURL = "http://localhost:3000/"
|
||||
setting.AppSubURL = ""
|
||||
assert.False(t, IsCurrentGiteaSiteURL(ctx, "//"))
|
||||
assert.False(t, IsCurrentGiteaSiteURL(ctx, "\\\\"))
|
||||
assert.False(t, IsCurrentGiteaSiteURL(ctx, "http://localhost"))
|
||||
assert.True(t, IsCurrentGiteaSiteURL(ctx, "http://localhost:3000?key=val"))
|
||||
|
||||
ctx = context.WithValue(ctx, RequestContextKey, &http.Request{
|
||||
Host: "user-host",
|
||||
Header: map[string][]string{
|
||||
"X-Forwarded-Host": {"forwarded-host"},
|
||||
"X-Forwarded-Proto": {"https"},
|
||||
},
|
||||
})
|
||||
assert.True(t, IsCurrentGiteaSiteURL(ctx, "http://localhost:3000"))
|
||||
assert.True(t, IsCurrentGiteaSiteURL(ctx, "https://user-host"))
|
||||
assert.False(t, IsCurrentGiteaSiteURL(ctx, "https://forwarded-host"))
|
||||
}
|
||||
|
||||
func TestParseGiteaSiteURL(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
ctx := t.Context()
|
||||
tests := []struct {
|
||||
url string
|
||||
exp *GiteaSiteURL
|
||||
}{
|
||||
{"http://localhost:3000/sub?k=v", &GiteaSiteURL{RoutePath: ""}},
|
||||
{"http://localhost:3000/sub/", &GiteaSiteURL{RoutePath: ""}},
|
||||
{"http://localhost:3000/sub/foo", &GiteaSiteURL{RoutePath: "/foo"}},
|
||||
{"http://localhost:3000/sub/foo/bar", &GiteaSiteURL{RoutePath: "/foo/bar", OwnerName: "foo", RepoName: "bar"}},
|
||||
{"http://localhost:3000/sub/foo/bar/", &GiteaSiteURL{RoutePath: "/foo/bar", OwnerName: "foo", RepoName: "bar"}},
|
||||
{"http://localhost:3000/sub/attachments/bar", &GiteaSiteURL{RoutePath: "/attachments/bar"}},
|
||||
{"http://localhost:3000/other", nil},
|
||||
{"http://other/", nil},
|
||||
}
|
||||
for _, test := range tests {
|
||||
su := ParseGiteaSiteURL(ctx, test.url)
|
||||
assert.Equal(t, test.exp, su, "URL = %s", test.url)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user