mirror of
				https://github.com/imgproxy/imgproxy.git
				synced 2025-10-30 23:08:02 +02:00 
			
		
		
		
	Support OpenStack Swift Object Storage (#837)
* Add OpenStack Swift support
* Fix linting errors
* Update CHANGELOG
* Update swift documentation
* Fix linting error
* Swift transport: pass req.Context down, fix parseObjectURL() implementation
* Make swift transport test a test suite
* Use swift://{container}/{object_path} as the source image URL format
* Cleanup
* Swift transport: only close object when returning 304
			
			
This commit is contained in:
		| @@ -4,6 +4,7 @@ | ||||
| ### Add | ||||
| - Add `IMGPROXY_FALLBACK_IMAGE_TTL` config. | ||||
| - Add [watermark_size](https://docs.imgproxy.net/generating_the_url?id=watermark-size) processing option. | ||||
| - Add OpenStack Object Storage ("Swift") support. | ||||
|  | ||||
| ### Change | ||||
| - (pro) Don't check `Content-Length` header of videos. | ||||
|   | ||||
| @@ -89,6 +89,16 @@ var ( | ||||
| 	ABSName             string | ||||
| 	ABSKey              string | ||||
| 	ABSEndpoint         string | ||||
| 	SwiftEnabled        bool | ||||
| 	SwiftUsername       string | ||||
| 	SwiftAPIKey         string | ||||
| 	SwiftAuthURL        string | ||||
| 	SwiftDomain         string | ||||
| 	SwiftTenant         string | ||||
| 	SwiftAuthVersion    int | ||||
|  | ||||
| 	SwiftConnectTimeoutSeconds int | ||||
| 	SwiftTimeoutSeconds        int | ||||
|  | ||||
| 	ETagEnabled bool | ||||
| 	ETagBuster  string | ||||
| @@ -230,6 +240,15 @@ func Reset() { | ||||
| 	ABSName = "" | ||||
| 	ABSKey = "" | ||||
| 	ABSEndpoint = "" | ||||
| 	SwiftEnabled = false | ||||
| 	SwiftUsername = "" | ||||
| 	SwiftAPIKey = "" | ||||
| 	SwiftAuthURL = "" | ||||
| 	SwiftAuthVersion = 0 | ||||
| 	SwiftTenant = "" | ||||
| 	SwiftDomain = "" | ||||
| 	SwiftConnectTimeoutSeconds = 10 | ||||
| 	SwiftTimeoutSeconds = 60 | ||||
|  | ||||
| 	ETagEnabled = false | ||||
| 	ETagBuster = "" | ||||
| @@ -384,6 +403,15 @@ func Configure() error { | ||||
| 	configurators.String(&ABSKey, "IMGPROXY_ABS_KEY") | ||||
| 	configurators.String(&ABSEndpoint, "IMGPROXY_ABS_ENDPOINT") | ||||
|  | ||||
| 	configurators.Bool(&SwiftEnabled, "IMGPROXY_USE_SWIFT") | ||||
| 	configurators.String(&SwiftUsername, "IMGPROXY_SWIFT_USERNAME") | ||||
| 	configurators.String(&SwiftAPIKey, "IMGPROXY_SWIFT_API_KEY") | ||||
| 	configurators.String(&SwiftAuthURL, "IMGPROXY_SWIFT_AUTH_URL") | ||||
| 	configurators.String(&SwiftDomain, "IMGPROXY_SWIFT_DOMAIN") | ||||
| 	configurators.String(&SwiftTenant, "IMGPROXY_SWIFT_TENANT") | ||||
| 	configurators.Int(&SwiftConnectTimeoutSeconds, "IMGPROXY_SWIFT_CONNECT_TIMEOUT_SECONDS") | ||||
| 	configurators.Int(&SwiftTimeoutSeconds, "IMGPROXY_SWIFT_TIMEOUT_SECONDS") | ||||
|  | ||||
| 	configurators.Bool(&ETagEnabled, "IMGPROXY_USE_ETAG") | ||||
| 	configurators.String(&ETagBuster, "IMGPROXY_ETAG_BUSTER") | ||||
|  | ||||
|   | ||||
| @@ -13,6 +13,7 @@ | ||||
| * [Serving files from Amazon S3](serving_files_from_s3) | ||||
| * [Serving files from Google Cloud Storage](serving_files_from_google_cloud_storage) | ||||
| * [Serving files from Azure Blob Storage](serving_files_from_azure_blob_storage) | ||||
| * [Serving files from OpenStack Object Storage ("Swift")](serving_files_from_openstack_swift) | ||||
| * [New Relic](new_relic) | ||||
| * [Prometheus](prometheus) | ||||
| * [Datadog](datadog) | ||||
|   | ||||
| @@ -325,6 +325,19 @@ imgproxy can process files from Azure Blob Storage containers, but this feature | ||||
|  | ||||
| Check out the [Serving files from Azure Blob Storage](serving_files_from_azure_blob_storage.md) guide to learn more. | ||||
|  | ||||
| ## Serving files from OpenStack Object Storage ("Swift") | ||||
| imgproxy can process files from OpenStack Object Storage, but this feature is disabled by default. To enable it, set `IMGPROXY_USE_SWIFT` to `true`. | ||||
| * `IMGPROXY_USE_SWIFT`: when `true`, enables image fetching from OpenStack Swift Object Storage. Default: `false` | ||||
| * `IMGPROXY_SWIFT_USERNAME`: the username for Swift API access. Default: blank | ||||
| * `IMGPROXY_SWIFT_API_KEY`: the API key for Swift API access. Default: blank | ||||
| * `IMGPROXY_SWIFT_AUTH_URL`: the Swift Auth URL. Default: blank | ||||
| * `IMGPROXY_SWIFT_AUTH_VERSION`: the Swift auth version, set to 1, 2 or 3 or leave at 0 for autodetect. | ||||
| * `IMGPROXY_SWIFT_TENANT`: the tenant name (optional, v2 auth only). Default: blank | ||||
| * `IMGPROXY_SWIFT_DOMAIN`: the Swift domain name (optional, v3 auth only): Default: blank | ||||
| * `IMGRPOXY_SWIFT_TIMEOUT_SECONDS`: the data channel timeout in seconds. Default: 60 | ||||
| * `IMGRPOXY_SWIFT_CONNECT_TIMEOUT_SECONDS`: the connect channel timeout in seconds. Default: 10 | ||||
|  | ||||
|  | ||||
| ## New Relic metrics | ||||
|  | ||||
| imgproxy can send its metrics to New Relic. Specify your New Relic license key to activate this feature: | ||||
|   | ||||
							
								
								
									
										16
									
								
								docs/serving_files_from_openstack_swift.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								docs/serving_files_from_openstack_swift.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # Serving files from OpenStack Object Storage ("Swift") | ||||
|  | ||||
| imgproxy can process images from OpenStack Object Storage, also known as Swift. To use this feature, do the following: | ||||
|  | ||||
| 1. Set `IMGPROXY_USE_SWIFT` environment variable to `true` | ||||
| 2. Configure Swift authentication with the following environment variables | ||||
|    * `IMGPROXY_SWIFT_USERNAME`: the username for Swift API access. Default: blank | ||||
|    * `IMGPROXY_SWIFT_API_KEY`: the API key for Swift API access. Default: blank | ||||
|    * `IMGPROXY_SWIFT_AUTH_URL`: the Swift Auth URL. Default: blank | ||||
|    * `IMGPROXY_SWIFT_AUTH_VERSION`: the Swift auth version, set to 1, 2 or 3 or leave at 0 for autodetect. | ||||
|    * `IMGPROXY_SWIFT_TENANT`: the tenant name (optional, v2 auth only). Default: blank | ||||
|    * `IMGPROXY_SWIFT_DOMAIN`: the Swift domain name (optional, v3 auth only): Default: blank | ||||
|  | ||||
| 3. Use `swift://%{container}/%{object_path}` as the source image URL. e.g. an original object storage URL in the format of | ||||
|    `/v1/{account}/{container}/{object_path}` such as `http://127.0.0.1:8080/v1/AUTH_test/images/flowers/rose.jpg` should | ||||
|    be converted to `swift://images/flowers/rose.jpg`.  | ||||
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							| @@ -16,6 +16,7 @@ require ( | ||||
| 	github.com/honeybadger-io/honeybadger-go v0.5.0 | ||||
| 	github.com/ianlancetaylor/cgosymbolizer v0.0.0-20220217162856-c813f11194b9 // indirect | ||||
| 	github.com/matoous/go-nanoid/v2 v2.0.0 | ||||
| 	github.com/ncw/swift/v2 v2.0.1 | ||||
| 	github.com/newrelic/go-agent/v3 v3.15.2 | ||||
| 	github.com/prometheus/client_golang v1.12.1 | ||||
| 	github.com/sirupsen/logrus v1.8.1 | ||||
| @@ -32,3 +33,5 @@ require ( | ||||
| replace git.apache.org/thrift.git => github.com/apache/thrift v0.0.0-20180902110319-2566ecd5d999 | ||||
|  | ||||
| replace github.com/shirou/gopsutil => github.com/shirou/gopsutil v2.20.9+incompatible | ||||
|  | ||||
| replace github.com/go-chi/chi/v4 => github.com/go-chi/chi v4.0.0+incompatible | ||||
|   | ||||
							
								
								
									
										3
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.sum
									
									
									
									
									
								
							| @@ -298,6 +298,7 @@ github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0 | ||||
| github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||
| github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= | ||||
| github.com/go-chi/chi v1.5.0/go.mod h1:REp24E+25iKvxgeTfHmdUoL5x15kBiDBlnIl5bCwe2k= | ||||
| github.com/go-chi/chi v4.0.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= | ||||
| github.com/go-chi/chi/v4 v4.0.0-rc1/go.mod h1:Yfiy+5nynjDc7IMJiguACIro1KxlGW2dLUqcroaEUEY= | ||||
| github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= | ||||
| github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= | ||||
| @@ -771,6 +772,8 @@ github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5Vgl | ||||
| github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= | ||||
| github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= | ||||
| github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= | ||||
| github.com/ncw/swift/v2 v2.0.1 h1:q1IN8hNViXEv8Zvg3Xdis4a3c4IlIGezkYz09zQL5J0= | ||||
| github.com/ncw/swift/v2 v2.0.1/go.mod h1:z0A9RVdYPjNjXVo2pDOPxZ4eu3oarO1P91fTItcb+Kg= | ||||
| github.com/newrelic/go-agent/v3 v3.15.2 h1:NEpksu2AhuZncbwkDqUg2IvUJst3JQ/TemYfK4WdS/Y= | ||||
| github.com/newrelic/go-agent/v3 v3.15.2/go.mod h1:1A1dssWBwzB7UemzRU6ZVaGDsI+cEn5/bNxI0wiYlIc= | ||||
| github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= | ||||
|   | ||||
| @@ -17,6 +17,7 @@ import ( | ||||
| 	fsTransport "github.com/imgproxy/imgproxy/v3/transport/fs" | ||||
| 	gcsTransport "github.com/imgproxy/imgproxy/v3/transport/gcs" | ||||
| 	s3Transport "github.com/imgproxy/imgproxy/v3/transport/s3" | ||||
| 	swiftTransport "github.com/imgproxy/imgproxy/v3/transport/swift" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| @@ -94,6 +95,14 @@ func initDownloading() error { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if config.SwiftEnabled { | ||||
| 		if t, err := swiftTransport.New(); err != nil { | ||||
| 			return err | ||||
| 		} else { | ||||
| 			registerProtocol("swift", t) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	downloadClient = &http.Client{ | ||||
| 		Timeout:   time.Duration(config.DownloadTimeout) * time.Second, | ||||
| 		Transport: transport, | ||||
|   | ||||
							
								
								
									
										88
									
								
								transport/swift/swift.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								transport/swift/swift.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| package swift | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/imgproxy/imgproxy/v3/config" | ||||
| 	"github.com/ncw/swift/v2" | ||||
| ) | ||||
|  | ||||
| type transport struct { | ||||
| 	con *swift.Connection | ||||
| } | ||||
|  | ||||
| func New() (http.RoundTripper, error) { | ||||
| 	c := &swift.Connection{ | ||||
| 		UserName:       config.SwiftUsername, | ||||
| 		ApiKey:         config.SwiftAPIKey, | ||||
| 		AuthUrl:        config.SwiftAuthURL, | ||||
| 		AuthVersion:    config.SwiftAuthVersion, | ||||
| 		Domain:         config.SwiftDomain, // v3 auth only | ||||
| 		Tenant:         config.SwiftTenant, // v2 auth only | ||||
| 		Timeout:        time.Duration(config.SwiftTimeoutSeconds) * time.Second, | ||||
| 		ConnectTimeout: time.Duration(config.SwiftConnectTimeoutSeconds) * time.Second, | ||||
| 	} | ||||
|  | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	err := c.Authenticate(ctx) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("swift authentication error: %s", err) | ||||
| 	} | ||||
|  | ||||
| 	return transport{con: c}, nil | ||||
| } | ||||
|  | ||||
| func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error) { | ||||
| 	// Users should have converted the object storage URL in the format of swift://{container}/{object} | ||||
| 	container := req.URL.Host | ||||
| 	objectName := strings.TrimPrefix(req.URL.Path, "/") | ||||
|  | ||||
| 	headers := make(swift.Headers) | ||||
|  | ||||
| 	object, headers, err := t.con.ObjectOpen(req.Context(), container, objectName, false, headers) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error opening object: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	header := make(http.Header) | ||||
|  | ||||
| 	if config.ETagEnabled { | ||||
| 		if etag, ok := headers["Etag"]; ok { | ||||
| 			header.Set("ETag", etag) | ||||
|  | ||||
| 			if len(etag) > 0 && etag == req.Header.Get("If-None-Match") { | ||||
| 				object.Close() | ||||
| 				return &http.Response{ | ||||
| 					StatusCode:    http.StatusNotModified, | ||||
| 					Proto:         "HTTP/1.0", | ||||
| 					ProtoMajor:    1, | ||||
| 					ProtoMinor:    0, | ||||
| 					Header:        header, | ||||
| 					ContentLength: 0, | ||||
| 					Body:          nil, | ||||
| 					Close:         false, | ||||
| 					Request:       req, | ||||
| 				}, nil | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return &http.Response{ | ||||
| 		Status:     "200 OK", | ||||
| 		StatusCode: 200, | ||||
| 		Proto:      "HTTP/1.0", | ||||
| 		ProtoMajor: 1, | ||||
| 		ProtoMinor: 0, | ||||
| 		Header:     header, | ||||
| 		Body:       object, | ||||
| 		Close:      true, | ||||
| 		Request:    req, | ||||
| 	}, nil | ||||
| } | ||||
							
								
								
									
										127
									
								
								transport/swift/swift_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								transport/swift/swift_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| package swift | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"io/ioutil" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/imgproxy/imgproxy/v3/config" | ||||
| 	"github.com/ncw/swift/v2" | ||||
| 	"github.com/ncw/swift/v2/swifttest" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/suite" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	testContainer = "test" | ||||
| 	testObject    = "foo/test.png" | ||||
| ) | ||||
|  | ||||
| type SwiftTestSuite struct { | ||||
| 	suite.Suite | ||||
| 	server    *swifttest.SwiftServer | ||||
| 	transport http.RoundTripper | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) SetupSuite() { | ||||
| 	s.server, _ = swifttest.NewSwiftServer("localhost") | ||||
|  | ||||
| 	config.Reset() | ||||
|  | ||||
| 	config.SwiftAuthURL = s.server.AuthURL | ||||
| 	config.SwiftUsername = swifttest.TEST_ACCOUNT | ||||
| 	config.SwiftAPIKey = swifttest.TEST_ACCOUNT | ||||
| 	config.SwiftAuthVersion = 1 | ||||
|  | ||||
| 	s.setupTestFile() | ||||
|  | ||||
| 	var err error | ||||
| 	s.transport, err = New() | ||||
| 	assert.Nil(s.T(), err, "failed to initialize swift transport") | ||||
| 	assert.IsType(s.T(), transport{}, s.transport) | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) setupTestFile() { | ||||
| 	t := s.T() | ||||
| 	c := &swift.Connection{ | ||||
| 		UserName:    config.SwiftUsername, | ||||
| 		ApiKey:      config.SwiftAPIKey, | ||||
| 		AuthUrl:     config.SwiftAuthURL, | ||||
| 		AuthVersion: config.SwiftAuthVersion, | ||||
| 	} | ||||
|  | ||||
| 	ctx := context.Background() | ||||
|  | ||||
| 	err := c.Authenticate(ctx) | ||||
| 	assert.Nil(t, err, "failed to authenticate with test server") | ||||
|  | ||||
| 	err = c.ContainerCreate(ctx, testContainer, nil) | ||||
| 	assert.Nil(t, err, "failed to create container") | ||||
|  | ||||
| 	f, err := c.ObjectCreate(ctx, testContainer, testObject, true, "", "image/png", nil) | ||||
| 	assert.Nil(t, err, "failed to create object") | ||||
|  | ||||
| 	defer f.Close() | ||||
|  | ||||
| 	wd, err := os.Getwd() | ||||
| 	assert.Nil(t, err) | ||||
|  | ||||
| 	data, err := ioutil.ReadFile(filepath.Join(wd, "..", "..", "testdata", "test1.png")) | ||||
| 	assert.Nil(t, err, "failed to read testdata/test1.png") | ||||
|  | ||||
| 	b, err := f.Write(data) | ||||
| 	assert.Greater(t, b, 100) | ||||
| 	assert.Nil(t, err) | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) TearDownSuite() { | ||||
| 	s.server.Close() | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) TestRoundTripWithETagDisabledReturns200() { | ||||
| 	config.ETagEnabled = false | ||||
| 	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil) | ||||
|  | ||||
| 	response, err := s.transport.RoundTrip(request) | ||||
| 	assert.Nil(s.T(), err) | ||||
| 	assert.Equal(s.T(), 200, response.StatusCode) | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) TestRoundTripWithETagEnabled() { | ||||
| 	config.ETagEnabled = true | ||||
| 	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil) | ||||
|  | ||||
| 	response, err := s.transport.RoundTrip(request) | ||||
| 	assert.Nil(s.T(), err) | ||||
| 	assert.Equal(s.T(), 200, response.StatusCode) | ||||
| 	assert.Equal(s.T(), "e27ca34142be8e55220e44155c626cd0", response.Header.Get("ETag")) | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) TestRoundTripWithIfNoneMatchReturns304() { | ||||
| 	config.ETagEnabled = true | ||||
|  | ||||
| 	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil) | ||||
| 	request.Header.Set("If-None-Match", "e27ca34142be8e55220e44155c626cd0") | ||||
|  | ||||
| 	response, err := s.transport.RoundTrip(request) | ||||
| 	assert.Nil(s.T(), err) | ||||
| 	assert.Equal(s.T(), http.StatusNotModified, response.StatusCode) | ||||
| } | ||||
|  | ||||
| func (s *SwiftTestSuite) TestRoundTripWithUpdatedETagReturns200() { | ||||
| 	config.ETagEnabled = true | ||||
|  | ||||
| 	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil) | ||||
| 	request.Header.Set("If-None-Match", "foobar") | ||||
|  | ||||
| 	response, err := s.transport.RoundTrip(request) | ||||
| 	assert.Nil(s.T(), err) | ||||
| 	assert.Equal(s.T(), http.StatusOK, response.StatusCode) | ||||
| } | ||||
|  | ||||
| func TestSwiftTransport(t *testing.T) { | ||||
| 	suite.Run(t, new(SwiftTestSuite)) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user