Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
92
server/platform/services/imageproxy/atmos_camo.go
Обычный файл
92
server/platform/services/imageproxy/atmos_camo.go
Обычный файл
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type AtmosCamoBackend struct {
|
||||
proxy *ImageProxy
|
||||
siteURL *url.URL
|
||||
remoteURL *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func makeAtmosCamoBackend(proxy *ImageProxy) *AtmosCamoBackend {
|
||||
// We deliberately ignore the error because it's from config.json.
|
||||
// The function returns a nil pointer in case of error, and we handle it when it's used.
|
||||
siteURL, _ := url.Parse(*proxy.ConfigService.Config().ServiceSettings.SiteURL)
|
||||
remoteURL, _ := url.Parse(*proxy.ConfigService.Config().ImageProxySettings.RemoteImageProxyURL)
|
||||
|
||||
return &AtmosCamoBackend{
|
||||
proxy: proxy,
|
||||
siteURL: siteURL,
|
||||
remoteURL: remoteURL,
|
||||
client: proxy.HTTPService.MakeClient(false),
|
||||
}
|
||||
}
|
||||
|
||||
func (backend *AtmosCamoBackend) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
|
||||
http.Redirect(w, r, backend.getAtmosCamoImageURL(imageURL), http.StatusFound)
|
||||
}
|
||||
|
||||
func (backend *AtmosCamoBackend) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
|
||||
req, err := http.NewRequest("GET", backend.getAtmosCamoImageURL(imageURL), nil)
|
||||
if err != nil {
|
||||
return nil, "", Error{err}
|
||||
}
|
||||
|
||||
resp, err := backend.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", Error{err}
|
||||
}
|
||||
|
||||
// Note that we don't do any additional validation of the received data since we expect the image proxy to do that
|
||||
return resp.Body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func (backend *AtmosCamoBackend) getAtmosCamoImageURL(imageURL string) string {
|
||||
cfg := *backend.proxy.ConfigService.Config()
|
||||
options := *cfg.ImageProxySettings.RemoteImageProxyOptions
|
||||
|
||||
if imageURL == "" || backend.siteURL == nil {
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// Parse url, return siteURL in case of failure.
|
||||
// Also if the URL is opaque.
|
||||
parsedURL, err := url.Parse(imageURL)
|
||||
if err != nil || parsedURL.Opaque != "" {
|
||||
return backend.siteURL.String()
|
||||
}
|
||||
|
||||
// If host is same as siteURL host/ remoteURL host, return.
|
||||
if parsedURL.Host == backend.siteURL.Host || parsedURL.Host == backend.remoteURL.Host {
|
||||
return parsedURL.String()
|
||||
}
|
||||
|
||||
// Handle protocol-relative URLs.
|
||||
if parsedURL.Scheme == "" {
|
||||
parsedURL.Scheme = backend.siteURL.Scheme
|
||||
}
|
||||
|
||||
// If it's a relative URL, fill up the hostname and scheme and return.
|
||||
if parsedURL.Host == "" {
|
||||
parsedURL.Host = backend.siteURL.Host
|
||||
return parsedURL.String()
|
||||
}
|
||||
|
||||
urlBytes := []byte(parsedURL.String())
|
||||
mac := hmac.New(sha1.New, []byte(options))
|
||||
mac.Write(urlBytes)
|
||||
digest := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return backend.remoteURL.String() + "/" + digest + "/" + hex.EncodeToString(urlBytes)
|
||||
}
|
||||
189
server/platform/services/imageproxy/atmos_camo_test.go
Обычный файл
189
server/platform/services/imageproxy/atmos_camo_test.go
Обычный файл
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils/testutils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/httpservice"
|
||||
)
|
||||
|
||||
func makeTestAtmosCamoProxy() *ImageProxy {
|
||||
configService := &testutils.StaticConfigService{
|
||||
Cfg: &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("https://mattermost.example.com"),
|
||||
AllowedUntrustedInternalConnections: model.NewString("127.0.0.1"),
|
||||
},
|
||||
ImageProxySettings: model.ImageProxySettings{
|
||||
Enable: model.NewBool(true),
|
||||
ImageProxyType: model.NewString(model.ImageProxyTypeAtmosCamo),
|
||||
RemoteImageProxyURL: model.NewString("http://images.example.com"),
|
||||
RemoteImageProxyOptions: model.NewString("7e5f3fab20b94782b43cdb022a66985ef28ba355df2c5d5da3c9a05e4b697bac"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return MakeImageProxy(configService, httpservice.MakeHTTPService(configService), nil)
|
||||
}
|
||||
|
||||
func TestAtmosCamoBackend_GetImage(t *testing.T) {
|
||||
imageURL := "https://www.mattermost.com/wp-content/uploads/2022/02/logoHorizontalWhite.png"
|
||||
proxiedURL := "http://images.example.com/b569ce17f1be4550cffa8d8dd3a9e80e6d209584/68747470733a2f2f7777772e6d61747465726d6f73742e636f6d2f77702d636f6e74656e742f75706c6f6164732f323032322f30322f6c6f676f486f72697a6f6e74616c57686974652e706e67"
|
||||
|
||||
proxy := makeTestAtmosCamoProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, imageURL)
|
||||
resp := recorder.Result()
|
||||
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||
assert.Equal(t, proxiedURL, resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestAtmosCamoBackend_GetImageDirect(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestAtmosCamoProxy()
|
||||
parsedURL, err := url.Parse(*proxy.ConfigService.Config().ServiceSettings.SiteURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
remoteURL, err := url.Parse(mock.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
backend := &AtmosCamoBackend{
|
||||
proxy: proxy,
|
||||
siteURL: parsedURL,
|
||||
remoteURL: remoteURL,
|
||||
client: proxy.HTTPService.MakeClient(false),
|
||||
}
|
||||
|
||||
body, contentType, err := backend.GetImageDirect("https://example.com/image.png")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "image/png", contentType)
|
||||
|
||||
require.NotNil(t, body)
|
||||
respBody, _ := io.ReadAll(body)
|
||||
assert.Equal(t, []byte("1111111111"), respBody)
|
||||
}
|
||||
|
||||
func TestGetAtmosCamoImageURL(t *testing.T) {
|
||||
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
|
||||
proxiedURL := "http://images.example.com/03b122734ae088d10cb46ea05512ec7dc852299e/68747470733a2f2f6d61747465726d6f73742e636f6d2f77702d636f6e74656e742f75706c6f6164732f323032322f30322f6c6f676f486f72697a6f6e74616c2e706e67"
|
||||
|
||||
defaultSiteURL := "https://mattermost.example.com"
|
||||
proxyURL := "http://images.example.com"
|
||||
|
||||
for _, test := range []struct {
|
||||
Name string
|
||||
Input string
|
||||
SiteURL string
|
||||
Expected string
|
||||
}{
|
||||
{
|
||||
Name: "should proxy image",
|
||||
Input: imageURL,
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should proxy image when no site URL is set",
|
||||
Input: imageURL,
|
||||
SiteURL: "",
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should proxy image when a site URL with a subpath is set",
|
||||
Input: imageURL,
|
||||
SiteURL: proxyURL + "/subpath",
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should not proxy a relative image",
|
||||
Input: "/static/logo.png",
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: "https://mattermost.example.com/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should bypass opaque URLs",
|
||||
Input: "http:xyz123?query",
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: defaultSiteURL,
|
||||
},
|
||||
{
|
||||
Name: "should not proxy an image on the Mattermost server",
|
||||
Input: "https://mattermost.example.com/static/logo.png",
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: "https://mattermost.example.com/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not proxy an image on the Mattermost server when a subpath is set",
|
||||
Input: "https://mattermost.example.com/static/logo.png",
|
||||
SiteURL: defaultSiteURL + "/static",
|
||||
Expected: "https://mattermost.example.com/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not proxy an image that has already been proxied",
|
||||
Input: proxiedURL,
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should not bypass protocol relative URLs",
|
||||
Input: "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png",
|
||||
SiteURL: "http://mattermost.example.com",
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should not bypass if the host prefix is same",
|
||||
Input: "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png",
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: "http://images.example.com/03b122734ae088d10cb46ea05512ec7dc852299e/68747470733a2f2f6d61747465726d6f73742e636f6d2f77702d636f6e74656e742f75706c6f6164732f323032322f30322f6c6f676f486f72697a6f6e74616c2e706e67",
|
||||
},
|
||||
{
|
||||
Name: "should not bypass for user auth URLs",
|
||||
Input: "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png",
|
||||
SiteURL: defaultSiteURL,
|
||||
Expected: "http://images.example.com/03b122734ae088d10cb46ea05512ec7dc852299e/68747470733a2f2f6d61747465726d6f73742e636f6d2f77702d636f6e74656e742f75706c6f6164732f323032322f30322f6c6f676f486f72697a6f6e74616c2e706e67",
|
||||
},
|
||||
} {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
parsedURL, err := url.Parse(test.SiteURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
remoteURL, err := url.Parse(proxyURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
backend := &AtmosCamoBackend{
|
||||
proxy: makeTestAtmosCamoProxy(),
|
||||
siteURL: parsedURL,
|
||||
remoteURL: remoteURL,
|
||||
}
|
||||
|
||||
assert.Equal(t, test.Expected, backend.getAtmosCamoImageURL(test.Input))
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
8
server/platform/services/imageproxy/error.go
Обычный файл
8
server/platform/services/imageproxy/error.go
Обычный файл
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
type Error struct {
|
||||
error
|
||||
}
|
||||
176
server/platform/services/imageproxy/imageproxy.go
Обычный файл
176
server/platform/services/imageproxy/imageproxy.go
Обычный файл
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/configservice"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/httpservice"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var ErrNotEnabled = Error{errors.New("imageproxy.ImageProxy: image proxy not enabled")}
|
||||
|
||||
// An ImageProxy is the public interface for Mattermost's image proxy. An instance of ImageProxy should be created
|
||||
// using MakeImageProxy which requires a configService and an HTTPService provided by the server.
|
||||
type ImageProxy struct {
|
||||
ConfigService configservice.ConfigService
|
||||
configListenerID string
|
||||
|
||||
HTTPService httpservice.HTTPService
|
||||
|
||||
Logger *mlog.Logger
|
||||
|
||||
siteURL *url.URL
|
||||
lock sync.RWMutex
|
||||
backend ImageProxyBackend
|
||||
}
|
||||
|
||||
// An ImageProxyBackend provides the functionality for different types of image proxies. An ImageProxy will construct
|
||||
// the required backend depending on the ImageProxySettings provided by the ConfigService.
|
||||
type ImageProxyBackend interface {
|
||||
// GetImage provides a proxied image in response to an HTTP request.
|
||||
GetImage(w http.ResponseWriter, r *http.Request, imageURL string)
|
||||
|
||||
// GetImageDirect returns a proxied image along with its content type.
|
||||
GetImageDirect(imageURL string) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func MakeImageProxy(configService configservice.ConfigService, httpService httpservice.HTTPService, logger *mlog.Logger) *ImageProxy {
|
||||
proxy := &ImageProxy{
|
||||
ConfigService: configService,
|
||||
HTTPService: httpService,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
// We deliberately ignore the error because it's from config.json.
|
||||
// The function returns a nil pointer in case of error, and we handle it when it's used.
|
||||
siteURL, _ := url.Parse(*configService.Config().ServiceSettings.SiteURL)
|
||||
proxy.siteURL = siteURL
|
||||
|
||||
proxy.configListenerID = proxy.ConfigService.AddConfigListener(proxy.OnConfigChange)
|
||||
|
||||
config := proxy.ConfigService.Config()
|
||||
proxy.backend = proxy.makeBackend(*config.ImageProxySettings.Enable, *config.ImageProxySettings.ImageProxyType)
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
func (proxy *ImageProxy) makeBackend(enable bool, proxyType string) ImageProxyBackend {
|
||||
if !enable {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch proxyType {
|
||||
case model.ImageProxyTypeLocal:
|
||||
return makeLocalBackend(proxy)
|
||||
case model.ImageProxyTypeAtmosCamo:
|
||||
return makeAtmosCamoBackend(proxy)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (proxy *ImageProxy) Close() {
|
||||
proxy.lock.Lock()
|
||||
defer proxy.lock.Unlock()
|
||||
|
||||
proxy.ConfigService.RemoveConfigListener(proxy.configListenerID)
|
||||
}
|
||||
|
||||
func (proxy *ImageProxy) OnConfigChange(oldConfig, newConfig *model.Config) {
|
||||
if *oldConfig.ImageProxySettings.Enable != *newConfig.ImageProxySettings.Enable ||
|
||||
*oldConfig.ImageProxySettings.ImageProxyType != *newConfig.ImageProxySettings.ImageProxyType {
|
||||
proxy.lock.Lock()
|
||||
defer proxy.lock.Unlock()
|
||||
|
||||
proxy.backend = proxy.makeBackend(*newConfig.ImageProxySettings.Enable, *newConfig.ImageProxySettings.ImageProxyType)
|
||||
}
|
||||
}
|
||||
|
||||
// GetImage takes an HTTP request for an image and requests that image using the image proxy.
|
||||
func (proxy *ImageProxy) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
|
||||
proxy.lock.RLock()
|
||||
defer proxy.lock.RUnlock()
|
||||
|
||||
if proxy.backend == nil {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
proxy.backend.GetImage(w, r, imageURL)
|
||||
}
|
||||
|
||||
// GetImageDirect takes the URL of an image and returns the image along with its content type.
|
||||
func (proxy *ImageProxy) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
|
||||
proxy.lock.RLock()
|
||||
defer proxy.lock.RUnlock()
|
||||
|
||||
if proxy.backend == nil {
|
||||
return nil, "", ErrNotEnabled
|
||||
}
|
||||
|
||||
return proxy.backend.GetImageDirect(imageURL)
|
||||
}
|
||||
|
||||
// GetProxiedImageURL takes the URL of an image and returns a URL that can be used to view that image through the
|
||||
// image proxy.
|
||||
func (proxy *ImageProxy) GetProxiedImageURL(imageURL string) string {
|
||||
if imageURL == "" || proxy.siteURL == nil {
|
||||
return imageURL
|
||||
}
|
||||
// Parse url, return siteURL in case of failure.
|
||||
// Also if the URL is opaque.
|
||||
parsedURL, err := url.Parse(imageURL)
|
||||
if err != nil || parsedURL.Opaque != "" {
|
||||
return proxy.siteURL.String()
|
||||
}
|
||||
// If host is same as siteURL host, return.
|
||||
if parsedURL.Host == proxy.siteURL.Host {
|
||||
return parsedURL.String()
|
||||
}
|
||||
|
||||
// Handle protocol-relative URLs.
|
||||
if parsedURL.Scheme == "" {
|
||||
parsedURL.Scheme = proxy.siteURL.Scheme
|
||||
}
|
||||
|
||||
// If it's a relative URL, fill up the hostname and return.
|
||||
if parsedURL.Host == "" {
|
||||
parsedURL.Host = proxy.siteURL.Host
|
||||
return parsedURL.String()
|
||||
}
|
||||
|
||||
return proxy.siteURL.String() + "/api/v4/image?url=" + url.QueryEscape(parsedURL.String())
|
||||
}
|
||||
|
||||
// GetUnproxiedImageURL takes the URL of an image on the image proxy and returns the original URL of the image.
|
||||
func (proxy *ImageProxy) GetUnproxiedImageURL(proxiedURL string) string {
|
||||
return getUnproxiedImageURL(proxiedURL, *proxy.ConfigService.Config().ServiceSettings.SiteURL)
|
||||
}
|
||||
|
||||
func getUnproxiedImageURL(proxiedURL, siteURL string) string {
|
||||
if !strings.HasPrefix(proxiedURL, siteURL+"/api/v4/image?url=") {
|
||||
return proxiedURL
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(proxiedURL)
|
||||
if err != nil {
|
||||
return proxiedURL
|
||||
}
|
||||
|
||||
u := parsed.Query()["url"]
|
||||
if len(u) == 0 {
|
||||
return proxiedURL
|
||||
}
|
||||
|
||||
return u[0]
|
||||
}
|
||||
112
server/platform/services/imageproxy/imageproxy_test.go
Обычный файл
112
server/platform/services/imageproxy/imageproxy_test.go
Обычный файл
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetProxiedImageURL(t *testing.T) {
|
||||
siteURL := "https://mattermost.example.com"
|
||||
parsedURL, err := url.Parse(siteURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
|
||||
proxiedURL := "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fwp-content%2Fuploads%2F2022%2F02%2FlogoHorizontal.png"
|
||||
|
||||
proxy := ImageProxy{siteURL: parsedURL}
|
||||
|
||||
for _, test := range []struct {
|
||||
Name string
|
||||
Input string
|
||||
Expected string
|
||||
}{
|
||||
{
|
||||
Name: "should proxy an image",
|
||||
Input: imageURL,
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should not proxy a relative image",
|
||||
Input: "/static/logo.png",
|
||||
Expected: "https://mattermost.example.com/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should bypass opaque URLs",
|
||||
Input: "http:xyz123?query",
|
||||
Expected: siteURL,
|
||||
},
|
||||
{
|
||||
Name: "should not proxy an image on the Mattermost server",
|
||||
Input: "https://mattermost.example.com/static/logo.png",
|
||||
Expected: "https://mattermost.example.com/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not proxy an image that has already been proxied",
|
||||
Input: proxiedURL,
|
||||
Expected: proxiedURL,
|
||||
},
|
||||
{
|
||||
Name: "should not bypass protocol relative URLs",
|
||||
Input: "//mattermost.com/static/logo.png",
|
||||
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fstatic%2Flogo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not bypass if the host prefix is same",
|
||||
Input: "https://mattermost.example.com.anothersite.com/static/logo.png",
|
||||
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.example.com.anothersite.com%2Fstatic%2Flogo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not bypass for user auth URLs",
|
||||
Input: "https://mattermost.example.com@anothersite.com/static/logo.png",
|
||||
Expected: "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.example.com%40anothersite.com%2Fstatic%2Flogo.png",
|
||||
},
|
||||
} {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.Equal(t, test.Expected, proxy.GetProxiedImageURL(test.Input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnproxiedImageURL(t *testing.T) {
|
||||
siteURL := "https://mattermost.example.com"
|
||||
|
||||
imageURL := "https://mattermost.com/wp-content/uploads/2022/02/logoHorizontal.png"
|
||||
proxiedURL := "https://mattermost.example.com/api/v4/image?url=https%3A%2F%2Fmattermost.com%2Fwp-content%2Fuploads%2F2022%2F02%2FlogoHorizontal.png"
|
||||
|
||||
for _, test := range []struct {
|
||||
Name string
|
||||
Input string
|
||||
Expected string
|
||||
}{
|
||||
{
|
||||
Name: "should remove proxy",
|
||||
Input: proxiedURL,
|
||||
Expected: imageURL,
|
||||
},
|
||||
{
|
||||
Name: "should not remove proxy from a relative image",
|
||||
Input: "/static/logo.png",
|
||||
Expected: "/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not remove proxy from an image on the Mattermost server",
|
||||
Input: "https://mattermost.example.com/static/logo.png",
|
||||
Expected: "https://mattermost.example.com/static/logo.png",
|
||||
},
|
||||
{
|
||||
Name: "should not remove proxy from a non-proxied image",
|
||||
Input: imageURL,
|
||||
Expected: imageURL,
|
||||
},
|
||||
} {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.Equal(t, test.Expected, getUnproxiedImageURL(test.Input, siteURL))
|
||||
})
|
||||
}
|
||||
}
|
||||
328
server/platform/services/imageproxy/local.go
Обычный файл
328
server/platform/services/imageproxy/local.go
Обычный файл
@@ -0,0 +1,328 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{
|
||||
"image/bmp", "image/cgm", "image/g3fax", "image/gif", "image/ief", "image/jp2",
|
||||
"image/jpeg", "image/jpg", "image/pict", "image/png", "image/prs.btif", "image/svg+xml",
|
||||
"image/tiff", "image/vnd.adobe.photoshop", "image/vnd.djvu", "image/vnd.dwg",
|
||||
"image/vnd.dxf", "image/vnd.fastbidsheet", "image/vnd.fpx", "image/vnd.fst",
|
||||
"image/vnd.fujixerox.edmics-mmr", "image/vnd.fujixerox.edmics-rlc",
|
||||
"image/vnd.microsoft.icon", "image/vnd.ms-modi", "image/vnd.net-fpx", "image/vnd.wap.wbmp",
|
||||
"image/vnd.xiff", "image/webp", "image/x-cmu-raster", "image/x-cmx", "image/x-icon",
|
||||
"image/x-macpaint", "image/x-pcx", "image/x-pict", "image/x-portable-anymap",
|
||||
"image/x-portable-bitmap", "image/x-portable-graymap", "image/x-portable-pixmap",
|
||||
"image/x-quicktime", "image/x-rgb", "image/x-xbitmap", "image/x-xpixmap", "image/x-xwindowdump",
|
||||
}
|
||||
|
||||
var msgNotAllowed = "requested URL is not allowed"
|
||||
|
||||
var ErrLocalRequestFailed = Error{errors.New("imageproxy.LocalBackend: failed to request proxied image")}
|
||||
|
||||
type LocalBackend struct {
|
||||
proxy *ImageProxy
|
||||
|
||||
client *http.Client
|
||||
baseURL *url.URL
|
||||
}
|
||||
|
||||
// URLError reports a malformed URL error.
|
||||
type URLError struct {
|
||||
Message string
|
||||
URL *url.URL
|
||||
}
|
||||
|
||||
func (e URLError) Error() string {
|
||||
return fmt.Sprintf("malformed URL %q: %s", e.URL, e.Message)
|
||||
}
|
||||
|
||||
func makeLocalBackend(proxy *ImageProxy) *LocalBackend {
|
||||
baseURL, err := url.Parse(*proxy.ConfigService.Config().ServiceSettings.SiteURL)
|
||||
if err != nil {
|
||||
mlog.Warn("Failed to set base URL for image proxy. Relative image links may not work.", mlog.Err(err))
|
||||
}
|
||||
|
||||
client := proxy.HTTPService.MakeClient(false)
|
||||
|
||||
return &LocalBackend{
|
||||
proxy: proxy,
|
||||
client: client,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
type contentTypeRecorder struct {
|
||||
http.ResponseWriter
|
||||
filename string
|
||||
}
|
||||
|
||||
func (rec *contentTypeRecorder) WriteHeader(code int) {
|
||||
hdr := rec.ResponseWriter.Header()
|
||||
contentType := hdr.Get("Content-Type")
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
// The error is caused by a malformed input and there's not much use logging it.
|
||||
// Therefore, even in the error case we set it to attachment mode to be safe.
|
||||
if err != nil || mediaType == "image/svg+xml" {
|
||||
hdr.Set("Content-Disposition", fmt.Sprintf("attachment;filename=%q", rec.filename))
|
||||
}
|
||||
|
||||
rec.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (backend *LocalBackend) GetImage(w http.ResponseWriter, r *http.Request, imageURL string) {
|
||||
// The interface to the proxy only exposes a ServeHTTP method, so fake a request to it
|
||||
req, err := http.NewRequest(http.MethodGet, "/"+imageURL, nil)
|
||||
if err != nil {
|
||||
// http.NewRequest should only return an error on an invalid URL
|
||||
mlog.Debug("Failed to create request for proxied image", mlog.String("url", imageURL), mlog.Err(err))
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte{})
|
||||
return
|
||||
}
|
||||
|
||||
u, err := url.Parse(imageURL)
|
||||
if err != nil {
|
||||
mlog.Debug("Failed to parse URL for proxied image", mlog.String("url", imageURL), mlog.Err(err))
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte{})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-Frame-Options", "deny")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src data:; style-src 'unsafe-inline'")
|
||||
|
||||
rec := contentTypeRecorder{w, filepath.Base(u.Path)}
|
||||
backend.ServeImage(&rec, req)
|
||||
}
|
||||
|
||||
func (backend *LocalBackend) GetImageDirect(imageURL string) (io.ReadCloser, string, error) {
|
||||
// The interface to the proxy only exposes a ServeHTTP method, so fake a request to it
|
||||
req, err := http.NewRequest(http.MethodGet, "/"+imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", Error{err}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
backend.ServeImage(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
return nil, "", ErrLocalRequestFailed
|
||||
}
|
||||
|
||||
return io.NopCloser(recorder.Body), recorder.Header().Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func (backend *LocalBackend) ServeImage(w http.ResponseWriter, req *http.Request) {
|
||||
proxyReq, err := newProxyRequest(req, backend.baseURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("invalid request URL: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
actualReq, err := http.NewRequest("GET", proxyReq.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
actualReq.Header.Set("Accept", strings.Join(imageContentTypes, ", "))
|
||||
|
||||
resp, err := backend.client.Do(actualReq)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("error fetching remote image: %v", err)
|
||||
mlog.Warn(msg)
|
||||
statusCode := http.StatusInternalServerError
|
||||
if e, ok := err.(net.Error); ok && e.Timeout() {
|
||||
statusCode = http.StatusGatewayTimeout
|
||||
}
|
||||
http.Error(w, msg, statusCode)
|
||||
return
|
||||
}
|
||||
// close the original resp.Body, even if we wrap it in a NopCloser below
|
||||
defer resp.Body.Close()
|
||||
|
||||
copyHeader(w.Header(), resp.Header, "Cache-Control", "Last-Modified", "Expires", "Etag", "Link")
|
||||
|
||||
if should304(req, resp) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if contentType == "" || contentType == "application/octet-stream" || contentType == "binary/octet-stream" {
|
||||
// try to detect content type
|
||||
b := bufio.NewReader(resp.Body)
|
||||
resp.Body = io.NopCloser(b)
|
||||
contentType = peekContentType(b)
|
||||
}
|
||||
if resp.ContentLength != 0 && !contentTypeMatches(imageContentTypes, contentType) {
|
||||
http.Error(w, msgNotAllowed, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
copyHeader(w.Header(), resp.Header, "Content-Length")
|
||||
|
||||
// Enable CORS for 3rd party applications
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Add a Content-Security-Policy to prevent stored-XSS attacks via SVG files
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'none'")
|
||||
|
||||
// Disable Content-Type sniffing
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// Block potential XSS attacks especially in legacy browsers which do not support CSP
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
mlog.Warn("error copying response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
// copyHeader copies header values from src to dst, adding to any existing
|
||||
// values with the same header name. If keys is not empty, only those header
|
||||
// keys will be copied.
|
||||
func copyHeader(dst, src http.Header, keys ...string) {
|
||||
if len(keys) == 0 {
|
||||
for k := range src {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
k := http.CanonicalHeaderKey(key)
|
||||
for _, v := range src[k] {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func should304(req *http.Request, resp *http.Response) bool {
|
||||
etag := resp.Header.Get("Etag")
|
||||
if etag != "" && etag == req.Header.Get("If-None-Match") {
|
||||
return true
|
||||
}
|
||||
|
||||
lastModified, err := time.Parse(time.RFC1123, resp.Header.Get("Last-Modified"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ifModSince, err := time.Parse(time.RFC1123, req.Header.Get("If-Modified-Since"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if lastModified.Before(ifModSince) || lastModified.Equal(ifModSince) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// peekContentType peeks at the first 512 bytes of p, and attempts to detect
|
||||
// the content type. Returns empty string if error occurs.
|
||||
func peekContentType(p *bufio.Reader) string {
|
||||
byt, err := p.Peek(512)
|
||||
if err != nil && err != bufio.ErrBufferFull && err != io.EOF {
|
||||
return ""
|
||||
}
|
||||
return http.DetectContentType(byt)
|
||||
}
|
||||
|
||||
// contentTypeMatches returns whether contentType matches one of the allowed patterns.
|
||||
func contentTypeMatches(patterns []string, contentType string) bool {
|
||||
if len(patterns) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if ok, err := path.Match(pattern, contentType); ok && err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// proxyRequest is an imageproxy request which includes a remote URL of an image to
|
||||
// proxy.
|
||||
type proxyRequest struct {
|
||||
URL *url.URL // URL of the image to proxy
|
||||
Original *http.Request // The original HTTP request
|
||||
}
|
||||
|
||||
// String returns the request URL as a string, with r.Options encoded in the
|
||||
// URL fragment.
|
||||
func (r proxyRequest) String() string {
|
||||
return r.URL.String()
|
||||
}
|
||||
|
||||
func newProxyRequest(r *http.Request, baseURL *url.URL) (*proxyRequest, error) {
|
||||
var err error
|
||||
req := &proxyRequest{Original: r}
|
||||
|
||||
path := r.URL.EscapedPath()[1:] // strip leading slash
|
||||
req.URL, err = parseURL(path)
|
||||
if err != nil || !req.URL.IsAbs() {
|
||||
// first segment should be options
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, URLError{"too few path segments", r.URL}
|
||||
}
|
||||
|
||||
var err error
|
||||
req.URL, err = parseURL(parts[1])
|
||||
if err != nil {
|
||||
return nil, URLError{fmt.Sprintf("unable to parse remote URL: %v", err), r.URL}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if baseURL != nil {
|
||||
req.URL = baseURL.ResolveReference(req.URL)
|
||||
}
|
||||
|
||||
if !req.URL.IsAbs() {
|
||||
return nil, URLError{"must provide absolute remote URL", r.URL}
|
||||
}
|
||||
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return nil, URLError{"remote URL must have http or https scheme", r.URL}
|
||||
}
|
||||
|
||||
// query string is always part of the remote URL
|
||||
req.URL.RawQuery = r.URL.RawQuery
|
||||
return req, nil
|
||||
}
|
||||
|
||||
var reCleanedURL = regexp.MustCompile(`^(https?):/+([^/])`)
|
||||
|
||||
// parseURL parses s as a URL, handling URLs that have been munged by
|
||||
// path.Clean or a webserver that collapses multiple slashes.
|
||||
func parseURL(s string) (*url.URL, error) {
|
||||
s = reCleanedURL.ReplaceAllString(s, "$1://$2")
|
||||
return url.Parse(s)
|
||||
}
|
||||
355
server/platform/services/imageproxy/local_test.go
Обычный файл
355
server/platform/services/imageproxy/local_test.go
Обычный файл
@@ -0,0 +1,355 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/server/channels/utils/testutils"
|
||||
"github.com/mattermost/mattermost-server/v6/server/platform/services/httpservice"
|
||||
)
|
||||
|
||||
func makeTestLocalProxy() *ImageProxy {
|
||||
configService := &testutils.StaticConfigService{
|
||||
Cfg: &model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
SiteURL: model.NewString("https://mattermost.example.com"),
|
||||
AllowedUntrustedInternalConnections: model.NewString("127.0.0.1"),
|
||||
},
|
||||
ImageProxySettings: model.ImageProxySettings{
|
||||
Enable: model.NewBool(true),
|
||||
ImageProxyType: model.NewString(model.ImageProxyTypeLocal),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return MakeImageProxy(configService, httpservice.MakeHTTPService(configService), nil)
|
||||
}
|
||||
|
||||
func TestLocalBackend_GetImage(t *testing.T) {
|
||||
t.Run("image", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/image.png")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "max-age=2592000, private", resp.Header.Get("Cache-Control"))
|
||||
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
assert.Equal(t, []byte("1111111111"), respBody)
|
||||
})
|
||||
|
||||
t.Run("not an image", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/file.pdf")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusNotAcceptable, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("not an image, but remote server ignores accept header", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/file.pdf")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/image.png")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("other server error", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/image.png")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
wait := make(chan bool, 1)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-wait
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
// Modify the timeout to be much shorter than the default 30 seconds
|
||||
proxy.backend.(*LocalBackend).client.Timeout = time.Millisecond
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/image.png")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
|
||||
|
||||
wait <- true
|
||||
})
|
||||
|
||||
t.Run("SVG attachment", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, err := http.NewRequest(http.MethodGet, "", nil)
|
||||
require.NoError(t, err)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/test.svg")
|
||||
resp := recorder.Result()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "attachment;filename=\"test.svg\"", resp.Header.Get("Content-Disposition"))
|
||||
|
||||
_, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Redirect", func(t *testing.T) {
|
||||
var mock *httptest.Server
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/image.png":
|
||||
w.Header().Set("Location", mock.URL+"/image2.png")
|
||||
w.WriteHeader(http.StatusMovedPermanently)
|
||||
case "/image2.png":
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
}
|
||||
})
|
||||
|
||||
mock = httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||
proxy.GetImage(recorder, request, mock.URL+"/image.png")
|
||||
resp := recorder.Result()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocalBackend_GetImageDirect(t *testing.T) {
|
||||
t.Run("image", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "image/png", contentType)
|
||||
|
||||
respBody, _ := io.ReadAll(body)
|
||||
assert.Equal(t, []byte("1111111111"), respBody)
|
||||
})
|
||||
|
||||
t.Run("not an image", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
body, contentType, err := proxy.GetImageDirect(mock.URL + "/file.pdf")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", contentType)
|
||||
assert.Equal(t, ErrLocalRequestFailed, err)
|
||||
assert.Nil(t, body)
|
||||
})
|
||||
|
||||
t.Run("not an image, but remote server ignores accept header", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "max-age=2592000, private")
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1111111111"))
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
body, contentType, err := proxy.GetImageDirect(mock.URL + "/file.pdf")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", contentType)
|
||||
assert.Equal(t, ErrLocalRequestFailed, err)
|
||||
assert.Nil(t, body)
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", contentType)
|
||||
assert.Equal(t, ErrLocalRequestFailed, err)
|
||||
assert.Nil(t, body)
|
||||
})
|
||||
|
||||
t.Run("other server error", func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", contentType)
|
||||
assert.Equal(t, ErrLocalRequestFailed, err)
|
||||
assert.Nil(t, body)
|
||||
})
|
||||
|
||||
t.Run("timeout", func(t *testing.T) {
|
||||
wait := make(chan bool, 1)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-wait
|
||||
})
|
||||
|
||||
mock := httptest.NewServer(handler)
|
||||
defer mock.Close()
|
||||
|
||||
proxy := makeTestLocalProxy()
|
||||
|
||||
// Modify the timeout to be much shorter than the default 30 seconds
|
||||
proxy.backend.(*LocalBackend).client.Timeout = time.Millisecond
|
||||
|
||||
body, contentType, err := proxy.GetImageDirect(mock.URL + "/image.png")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", contentType)
|
||||
assert.Equal(t, ErrLocalRequestFailed, err)
|
||||
assert.Nil(t, body)
|
||||
|
||||
wait <- true
|
||||
})
|
||||
}
|
||||
Ссылка в новой задаче
Block a user