初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
type ContentDispositionType string
|
||||
|
||||
const (
|
||||
ContentDispositionInline ContentDispositionType = "inline"
|
||||
ContentDispositionAttachment ContentDispositionType = "attachment"
|
||||
)
|
||||
|
||||
func needsEncodingRune(b rune) bool {
|
||||
return (b < ' ' || b > '~') && b != '\t'
|
||||
}
|
||||
|
||||
// getSafeName replaces all invalid chars in the filename field by underscore
|
||||
func getSafeName(s string) (_ string, needsEncoding bool) {
|
||||
var out strings.Builder
|
||||
for _, b := range s {
|
||||
if needsEncodingRune(b) {
|
||||
needsEncoding = true
|
||||
out.WriteRune('_')
|
||||
} else {
|
||||
out.WriteRune(b)
|
||||
}
|
||||
}
|
||||
return out.String(), needsEncoding
|
||||
}
|
||||
|
||||
func EncodeContentDispositionAttachment(filename string) string {
|
||||
return encodeContentDisposition(ContentDispositionAttachment, filename)
|
||||
}
|
||||
|
||||
func EncodeContentDispositionInline(filename string) string {
|
||||
return encodeContentDisposition(ContentDispositionInline, filename)
|
||||
}
|
||||
|
||||
// encodeContentDisposition encodes a correct Content-Disposition Header
|
||||
func encodeContentDisposition(t ContentDispositionType, filename string) string {
|
||||
safeFilename, needsEncoding := getSafeName(filename)
|
||||
result := mime.FormatMediaType(string(t), map[string]string{"filename": safeFilename})
|
||||
// No need for the utf8 encoding
|
||||
if !needsEncoding {
|
||||
return result
|
||||
}
|
||||
utf8Result := mime.FormatMediaType(string(t), map[string]string{"filename": filename})
|
||||
|
||||
// The mime package might have unexpected results in other go versions
|
||||
// Make tests instance fail, otherwise use the default behavior of the go mime package
|
||||
if !strings.HasPrefix(result, string(t)+"; filename=") || !strings.HasPrefix(utf8Result, string(t)+"; filename*=") {
|
||||
setting.PanicInDevOrTesting("Unexpected mime package result %s", result)
|
||||
return utf8Result
|
||||
}
|
||||
|
||||
encodedFileName := strings.TrimPrefix(utf8Result, string(t))
|
||||
return result + encodedFileName
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestContentDisposition(t *testing.T) {
|
||||
type testEntry struct {
|
||||
disposition ContentDispositionType
|
||||
filename string
|
||||
header string
|
||||
}
|
||||
table := []testEntry{
|
||||
{disposition: ContentDispositionInline, filename: "test.txt", header: "inline; filename=test.txt"},
|
||||
{disposition: ContentDispositionInline, filename: "test❌.txt", header: "inline; filename=test_.txt; filename*=utf-8''test%E2%9D%8C.txt"},
|
||||
{disposition: ContentDispositionInline, filename: "test ❌.txt", header: "inline; filename=\"test _.txt\"; filename*=utf-8''test%20%E2%9D%8C.txt"},
|
||||
{disposition: ContentDispositionInline, filename: "\"test.txt", header: "inline; filename=\"\\\"test.txt\""},
|
||||
{disposition: ContentDispositionInline, filename: "hello\tworld.txt", header: "inline; filename=\"hello\tworld.txt\""},
|
||||
{disposition: ContentDispositionAttachment, filename: "hello\tworld.txt", header: "attachment; filename=\"hello\tworld.txt\""},
|
||||
{disposition: ContentDispositionAttachment, filename: "hello\nworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Aworld.txt"},
|
||||
{disposition: ContentDispositionAttachment, filename: "hello\rworld.txt", header: "attachment; filename=hello_world.txt; filename*=utf-8''hello%0Dworld.txt"},
|
||||
}
|
||||
|
||||
// Check the needsEncodingRune replacer ranges except tab that is checked above
|
||||
// Any change in behavior should fail here
|
||||
for c := ' '; !needsEncodingRune(c); c++ {
|
||||
var header string
|
||||
switch {
|
||||
case strings.ContainsAny(string(c), ` (),/:;<=>?@[]`):
|
||||
header = "inline; filename=\"hello" + string(c) + "world.txt\""
|
||||
case strings.ContainsAny(string(c), `"\`):
|
||||
// This document advises against for backslash in quoted form:
|
||||
// https://datatracker.ietf.org/doc/html/rfc6266#appendix-D
|
||||
// However the mime package is not generating the filename* in this scenario
|
||||
header = "inline; filename=\"hello\\" + string(c) + "world.txt\""
|
||||
default:
|
||||
header = "inline; filename=hello" + string(c) + "world.txt"
|
||||
}
|
||||
table = append(table, testEntry{
|
||||
disposition: ContentDispositionInline,
|
||||
filename: "hello" + string(c) + "world.txt",
|
||||
header: header,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entry := range table {
|
||||
t.Run(string(entry.disposition)+"_"+entry.filename, func(t *testing.T) {
|
||||
encoded := encodeContentDisposition(entry.disposition, entry.filename)
|
||||
assert.Equal(t, entry.header, encoded)
|
||||
disposition, params, err := mime.ParseMediaType(encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(entry.disposition), disposition)
|
||||
assert.Equal(t, entry.filename, params["filename"])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultTransport = sync.OnceValue(func() http.RoundTripper {
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: DialContextWithTimeout(10 * time.Second), // it is good enough in modern days
|
||||
}
|
||||
})
|
||||
|
||||
func DialContextWithTimeout(timeout time.Duration) func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, address)
|
||||
}
|
||||
}
|
||||
|
||||
func NewRequest(url, method string) *Request {
|
||||
return &Request{
|
||||
url: url,
|
||||
req: &http.Request{
|
||||
Method: method,
|
||||
Header: make(http.Header),
|
||||
Proto: "HTTP/1.1", // FIXME: from legacy httplib, it shouldn't be hardcoded
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
},
|
||||
params: map[string]string{},
|
||||
|
||||
// ATTENTION: from legacy httplib, callers must pay more attention to it, it will cause annoying bugs when the response takes a long time
|
||||
readWriteTimeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
url string
|
||||
req *http.Request
|
||||
params map[string]string
|
||||
|
||||
readWriteTimeout time.Duration
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
// SetContext sets the request's Context
|
||||
func (r *Request) SetContext(ctx context.Context) *Request {
|
||||
r.req = r.req.WithContext(ctx)
|
||||
return r
|
||||
}
|
||||
|
||||
// SetTransport sets the request transport, if not set, will use httplib's default transport with environment proxy support
|
||||
// ATTENTION: the http.Transport has a connection pool, so it should be reused as much as possible, do not create a lot of transports
|
||||
func (r *Request) SetTransport(transport http.RoundTripper) *Request {
|
||||
r.transport = transport
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Request) SetReadWriteTimeout(readWriteTimeout time.Duration) *Request {
|
||||
r.readWriteTimeout = readWriteTimeout
|
||||
return r
|
||||
}
|
||||
|
||||
// Header set header item string in request.
|
||||
func (r *Request) Header(key, value string) *Request {
|
||||
r.req.Header.Set(key, value)
|
||||
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
|
||||
}
|
||||
|
||||
// Response executes request client and returns the response.
|
||||
// Caller MUST close the response body if no error occurs.
|
||||
func (r *Request) Response() (*http.Response, error) {
|
||||
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
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: r.transport,
|
||||
Timeout: r.readWriteTimeout,
|
||||
}
|
||||
if client.Transport == nil {
|
||||
client.Transport = defaultTransport()
|
||||
}
|
||||
|
||||
if r.req.Header.Get("User-Agent") == "" {
|
||||
r.req.Header.Set("User-Agent", "GiteaHttpLib")
|
||||
}
|
||||
|
||||
return client.Do(r.req)
|
||||
}
|
||||
|
||||
func (r *Request) GoString() string {
|
||||
return fmt.Sprintf("%s %s", r.req.Method, r.url)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
charsetModule "gitea.dev/modules/charset"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/httpcache"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
)
|
||||
|
||||
type ServeHeaderOptions struct {
|
||||
ContentType string // defaults to "application/octet-stream"
|
||||
ContentLength *int64
|
||||
|
||||
Filename string
|
||||
ContentDisposition ContentDispositionType
|
||||
|
||||
CacheIsPublic bool
|
||||
CacheDuration time.Duration // defaults to 5 minutes
|
||||
LastModified time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// Disable JS execution on the same origin, since we serve the file from the same origin as Gitea server.
|
||||
// This rule can be relaxed in the future as long as it is properly sandboxed.
|
||||
// "style-src" is for SVG inline styles (from Display SVG files as images instead of text #14101)
|
||||
serveHeaderCspDefault = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||
|
||||
// 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.
|
||||
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
|
||||
serveHeaderCspPdf = "default-src 'none'; style-src 'unsafe-inline'"
|
||||
|
||||
// For audios and videos, actually it doesn't really need CSP (just like Gitea <= 1.25)
|
||||
serveHeaderCspAudioVideo = ""
|
||||
)
|
||||
|
||||
func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
|
||||
header := w.Header()
|
||||
contentType = util.IfZero(contentType, typesniffer.MimeTypeApplicationOctetStream)
|
||||
header.Set("Content-Type", contentType)
|
||||
header.Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
csp := serveHeaderCspDefault
|
||||
if strings.HasPrefix(contentType, "application/pdf") {
|
||||
csp = serveHeaderCspPdf
|
||||
}
|
||||
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
|
||||
csp = serveHeaderCspAudioVideo
|
||||
}
|
||||
if csp != "" {
|
||||
header.Set("Content-Security-Policy", csp)
|
||||
} else {
|
||||
header.Del("Content-Security-Policy")
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
serveSetHeaderContentRelated(w, opts.ContentType)
|
||||
|
||||
if opts.ContentLength != nil {
|
||||
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
|
||||
}
|
||||
if opts.Filename != "" {
|
||||
contentDisposition := util.IfZero(opts.ContentDisposition, ContentDispositionAttachment)
|
||||
header.Set("Content-Disposition", encodeContentDisposition(contentDisposition, path.Base(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))
|
||||
}
|
||||
}
|
||||
|
||||
func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) {
|
||||
var detectCharset bool
|
||||
|
||||
if setting.MimeTypeMap.Enabled {
|
||||
fileExtension := strings.ToLower(path.Ext(opts.Filename))
|
||||
opts.ContentType = setting.MimeTypeMap.Map[fileExtension]
|
||||
detectCharset = strings.HasPrefix(opts.ContentType, "text/") && !strings.Contains(opts.ContentType, "charset=")
|
||||
}
|
||||
|
||||
if opts.ContentType == "" {
|
||||
sniffedType := typesniffer.DetectContentType(contentPrefetchBuf)
|
||||
if sniffedType.IsBrowsableBinaryType() {
|
||||
opts.ContentType = sniffedType.GetMimeType()
|
||||
} else if sniffedType.IsText() {
|
||||
// intentionally do not render user's HTML content as a page, for safety, and avoid content spamming & abusing
|
||||
opts.ContentType = "text/plain"
|
||||
detectCharset = true
|
||||
} else {
|
||||
opts.ContentType = typesniffer.MimeTypeApplicationOctetStream
|
||||
}
|
||||
}
|
||||
|
||||
if detectCharset {
|
||||
if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" {
|
||||
opts.ContentType += "; charset=" + strings.ToLower(charset)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.ContentDisposition == "" {
|
||||
sniffedType := typesniffer.FromContentType(opts.ContentType)
|
||||
opts.ContentDisposition = ContentDispositionInline
|
||||
if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
|
||||
opts.ContentDisposition = ContentDispositionAttachment
|
||||
}
|
||||
}
|
||||
|
||||
ServeSetHeaders(w, opts)
|
||||
}
|
||||
|
||||
const mimeDetectionBufferLen = 1024
|
||||
|
||||
func ServeUserContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) {
|
||||
if opts.ContentLength != nil {
|
||||
panic("do not set ContentLength, use size argument instead")
|
||||
}
|
||||
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]
|
||||
}
|
||||
serveSetHeadersByUserContent(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 seeker, ok := reader.(io.Seeker); ok {
|
||||
if _, err = seeker.Seek(start, io.SeekStart); err != nil {
|
||||
http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
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 ServeUserContentByFile(r *http.Request, w http.ResponseWriter, file fs.File, opts ServeHeaderOptions) {
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to serve file, stat error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
opts.LastModified = info.ModTime()
|
||||
ServeUserContentByReader(r, w, info.Size(), file, opts)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/modules/typesniffer"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServeUserContentByReader(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()
|
||||
ServeUserContentByReader(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 TestServeUserContentByFile(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()
|
||||
ServeUserContentByFile(r, w, 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:])
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeSetHeaderContentRelated(t *testing.T) {
|
||||
cases := []struct {
|
||||
contentType string
|
||||
csp string
|
||||
}{
|
||||
{"", serveHeaderCspDefault},
|
||||
{"any", serveHeaderCspDefault},
|
||||
{"application/pdf", serveHeaderCspPdf},
|
||||
{"application/pdf; other", serveHeaderCspPdf},
|
||||
{"audio/mp4", serveHeaderCspAudioVideo},
|
||||
{"video/ogg; other", serveHeaderCspAudioVideo},
|
||||
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
serveSetHeaderContentRelated(w, c.contentType)
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
assert.Equal(t, c.csp, csp, "content-type: %s", c.contentType)
|
||||
assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options")) // it should always be there
|
||||
}
|
||||
|
||||
// make sure sandboxed
|
||||
require.Contains(t, serveHeaderCspDefault, "; sandbox")
|
||||
}
|
||||
|
||||
func TestServeSetHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
ServeSetHeaders(w, ServeHeaderOptions{Filename: "foo.zip"})
|
||||
assert.Equal(t, "attachment; filename=foo.zip", w.Header().Get("Content-Disposition"))
|
||||
ServeSetHeaders(w, ServeHeaderOptions{Filename: "foo.zip", ContentDisposition: ContentDispositionInline})
|
||||
assert.Equal(t, "inline; filename=foo.zip", w.Header().Get("Content-Disposition"))
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/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
|
||||
}
|
||||
if u == nil {
|
||||
return false // invalid URL
|
||||
}
|
||||
if u.Scheme != "" || u.Host != "" {
|
||||
return false // absolute URL with scheme or host
|
||||
}
|
||||
// Now, the URL is likely a relative URL
|
||||
// HINT: GOLANG-HTTP-REDIRECT-BUG: Golang security vulnerability: "http.Redirect" calls "path.Clean" and changes the meaning of a path
|
||||
// For example, `/a/../\b` will be changed to `/\b`, then it hits the first checked pattern and becomes an open redirect to "{current-scheme}://b"
|
||||
// For a valid relative URL, its "path" shouldn't contain `\` because such char must be escaped.
|
||||
// So if the "path" contains `\`, it is not a valid relative URL, then we can prevent open redirect.
|
||||
return !strings.Contains(u.Path, "\\")
|
||||
}
|
||||
|
||||
// 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 proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Proto")); ok {
|
||||
return proto
|
||||
}
|
||||
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Protocol")); ok {
|
||||
return proto
|
||||
}
|
||||
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Url-Scheme")); ok {
|
||||
return proto
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
func parseForwardedProtoValue(val string) (string, bool) {
|
||||
if val == "http" || val == "https" {
|
||||
return val, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// "never" means always trust ROOT_URL and skip any request header detection.
|
||||
if setting.PublicURLDetection == setting.PublicURLNever {
|
||||
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/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",
|
||||
"/foo?k=\\",
|
||||
}
|
||||
for _, s := range rel {
|
||||
assert.True(t, IsRelativeURL(s), "rel = %q", s)
|
||||
}
|
||||
abs := []string{
|
||||
"//",
|
||||
"\\\\",
|
||||
"/\\",
|
||||
"\\/",
|
||||
"/a/../\\b",
|
||||
"/any\\thing",
|
||||
"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"}}
|
||||
maliciousProtoHeaders := http.Header{"X-Forwarded-Proto": {"http://attacker.host/?trash="}}
|
||||
|
||||
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))
|
||||
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: maliciousProtoHeaders})
|
||||
assert.Equal(t, "http://cfg-host", 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))
|
||||
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: maliciousProtoHeaders})
|
||||
assert.Equal(t, "http://req-host:3000", GuessCurrentHostURL(ctx))
|
||||
})
|
||||
|
||||
t.Run("Never", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
|
||||
|
||||
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(t.Context()))
|
||||
|
||||
ctx := context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000"})
|
||||
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
|
||||
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", TLS: &tls.ConnectionState{}})
|
||||
assert.Equal(t, "http://cfg-host", GuessCurrentHostURL(ctx))
|
||||
|
||||
ctx = context.WithValue(t.Context(), RequestContextKey, &http.Request{Host: "req-host:3000", Header: headersWithProto})
|
||||
assert.Equal(t, "http://cfg-host", 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