Преглед изворни кода

Support Last-Modified response header and support If-Modified-Since request header. (#1147)

* Always return Last-Modified and support If-Modified-Since.

* IMGPROXY_USE_LAST_MODIFIED config setting.

IMGPROXY_USE_LAST_MODIFIED  (default false) when enabled will return the
Last-Modified time of the upstream image and also allow the support of
the If-Modified-Since request header (returning a 304 if the image
hasn't been modified).

If-Modified-Since allows If-None-Match to take precedence.

* Fixes based on DarthSim's feedback.

1. Don't worry about nil maps.
2. Fix a test now that we use the config.LastModifiedEnabled (and move
   it's location it he test file to a more sane place).
3. Update GCS transport code based on the refactoring of DarthSim.

In this iteration, we pull the Updated time from the GCS object attributes
and format them as a string. We then parse it in the notmodified module.
Seems a bit silly to do it this way. If we agree on the approach here,
then AWS and Azure can follow.

* Support azure, fs, s3, and swift.

* Grab the headers for If-Modified-Since and Last-Modified before parsing them.

* Add tests for last-modified for fs.

* Support Last-Modified being passed when streaming an upstream file.

* Tests for Last-Modified for GCS and Azure

* Support s3 and swift tests. Sadly fakes3 doesn't support Last-Modified

* Test against forked gofakes3
Ewan Higgs пре 2 година
родитељ
комит
4944dfab30

+ 6 - 0
config/config.go

@@ -123,6 +123,8 @@ var (
 	ETagEnabled bool
 	ETagEnabled bool
 	ETagBuster  string
 	ETagBuster  string
 
 
+	LastModifiedEnabled bool
+
 	BaseURL string
 	BaseURL string
 
 
 	Presets     []string
 	Presets     []string
@@ -310,6 +312,8 @@ func Reset() {
 	ETagEnabled = false
 	ETagEnabled = false
 	ETagBuster = ""
 	ETagBuster = ""
 
 
+	LastModifiedEnabled = false
+
 	BaseURL = ""
 	BaseURL = ""
 
 
 	Presets = make([]string, 0)
 	Presets = make([]string, 0)
@@ -508,6 +512,8 @@ func Configure() error {
 	configurators.Bool(&ETagEnabled, "IMGPROXY_USE_ETAG")
 	configurators.Bool(&ETagEnabled, "IMGPROXY_USE_ETAG")
 	configurators.String(&ETagBuster, "IMGPROXY_ETAG_BUSTER")
 	configurators.String(&ETagBuster, "IMGPROXY_ETAG_BUSTER")
 
 
+	configurators.Bool(&LastModifiedEnabled, "IMGPROXY_USE_LAST_MODIFIED")
+
 	configurators.String(&BaseURL, "IMGPROXY_BASE_URL")
 	configurators.String(&BaseURL, "IMGPROXY_BASE_URL")
 
 
 	configurators.StringSlice(&Presets, "IMGPROXY_PRESETS")
 	configurators.StringSlice(&Presets, "IMGPROXY_PRESETS")

+ 1 - 0
imagedata/download.go

@@ -34,6 +34,7 @@ var (
 		"Cache-Control",
 		"Cache-Control",
 		"Expires",
 		"Expires",
 		"ETag",
 		"ETag",
+		"Last-Modified",
 	}
 	}
 
 
 	// For tests
 	// For tests

+ 19 - 2
processing_handler.go

@@ -91,6 +91,14 @@ func setCacheControl(rw http.ResponseWriter, force *time.Time, originHeaders map
 	}
 	}
 }
 }
 
 
+func setLastModified(rw http.ResponseWriter, originHeaders map[string]string) {
+	if config.LastModifiedEnabled {
+		if val, ok := originHeaders["Last-Modified"]; ok && len(val) != 0 {
+			rw.Header().Set("Last-Modified", val)
+		}
+	}
+}
+
 func setVary(rw http.ResponseWriter) {
 func setVary(rw http.ResponseWriter) {
 	if len(headerVaryValue) > 0 {
 	if len(headerVaryValue) > 0 {
 		rw.Header().Set("Vary", headerVaryValue)
 		rw.Header().Set("Vary", headerVaryValue)
@@ -118,6 +126,7 @@ func respondWithImage(reqID string, r *http.Request, rw http.ResponseWriter, sta
 	rw.Header().Set("Content-Disposition", contentDisposition)
 	rw.Header().Set("Content-Disposition", contentDisposition)
 
 
 	setCacheControl(rw, po.Expires, originData.Headers)
 	setCacheControl(rw, po.Expires, originData.Headers)
+	setLastModified(rw, originData.Headers)
 	setVary(rw)
 	setVary(rw)
 	setCanonical(rw, originURL)
 	setCanonical(rw, originURL)
 
 
@@ -260,6 +269,12 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 		}
 		}
 	}
 	}
 
 
+	if config.LastModifiedEnabled {
+		if modifiedSince := r.Header.Get("If-Modified-Since"); len(modifiedSince) != 0 {
+			imgRequestHeader.Set("If-Modified-Since", modifiedSince)
+		}
+	}
+
 	// The heavy part start here, so we need to restrict concurrency
 	// The heavy part start here, so we need to restrict concurrency
 	var processingSemToken *semaphore.Token
 	var processingSemToken *semaphore.Token
 	func() {
 	func() {
@@ -299,8 +314,10 @@ func handleProcessing(reqID string, rw http.ResponseWriter, r *http.Request) {
 
 
 	if err == nil {
 	if err == nil {
 		defer originData.Close()
 		defer originData.Close()
-	} else if nmErr, ok := err.(*imagedata.ErrorNotModified); ok && config.ETagEnabled {
-		rw.Header().Set("ETag", etagHandler.GenerateExpectedETag())
+	} else if nmErr, ok := err.(*imagedata.ErrorNotModified); ok {
+		if config.ETagEnabled && len(etagHandler.ImageEtagExpected()) != 0 {
+			rw.Header().Set("ETag", etagHandler.GenerateExpectedETag())
+		}
 		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers)
 		respondWithNotModified(reqID, r, rw, po, imageURL, nmErr.Headers)
 		return
 		return
 	} else {
 	} else {

+ 153 - 0
processing_handler_test.go

@@ -11,6 +11,7 @@ import (
 	"regexp"
 	"regexp"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config/configurators"
 	"github.com/imgproxy/imgproxy/v3/config/configurators"
@@ -550,6 +551,158 @@ func (s *ProcessingHandlerTestSuite) TestETagProcessingOptionsNotMatch() {
 	require.Equal(s.T(), actualETag, res.Header.Get("ETag"))
 	require.Equal(s.T(), actualETag, res.Header.Get("ETag"))
 }
 }
 
 
+func (s *ProcessingHandlerTestSuite) TestLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		rw.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
+		rw.WriteHeader(200)
+		rw.Write(s.readTestFile("test1.png"))
+	}))
+	defer ts.Close()
+
+	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
+	res := rw.Result()
+
+	require.Equal(s.T(), "Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get("Last-Modified"))
+}
+
+func (s *ProcessingHandlerTestSuite) TestLastModifiedDisabled() {
+	config.LastModifiedEnabled = false
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		rw.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
+		rw.WriteHeader(200)
+		rw.Write(s.readTestFile("test1.png"))
+	}))
+	defer ts.Close()
+
+	rw := s.send("/unsafe/rs:fill:4:4/plain/" + ts.URL)
+	res := rw.Result()
+
+	require.Equal(s.T(), "", res.Header.Get("Last-Modified"))
+}
+
+func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedDisabled() {
+	config.LastModifiedEnabled = false
+	data := s.readTestFile("test1.png")
+	lastModified := "Wed, 21 Oct 2015 07:28:00 GMT"
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		modifiedSince := r.Header.Get("If-Modified-Since")
+		require.Equal(s.T(), "", modifiedSince)
+		rw.WriteHeader(200)
+		rw.Write(data)
+
+	}))
+	defer ts.Close()
+
+	header := make(http.Header)
+	header.Set("If-Modified-Since", lastModified)
+	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
+	res := rw.Result()
+
+	require.Equal(s.T(), 200, res.StatusCode)
+}
+func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqExactMatchLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	lastModified := "Wed, 21 Oct 2015 07:28:00 GMT"
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		modifiedSince := r.Header.Get("If-Modified-Since")
+		require.Equal(s.T(), lastModified, modifiedSince)
+		rw.WriteHeader(304)
+	}))
+	defer ts.Close()
+
+	header := make(http.Header)
+	header.Set("If-Modified-Since", lastModified)
+	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
+	res := rw.Result()
+
+	require.Equal(s.T(), 304, res.StatusCode)
+}
+
+func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastModifiedDisabled() {
+	data := s.readTestFile("test1.png")
+	config.LastModifiedEnabled = false
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		modifiedSince := r.Header.Get("If-Modified-Since")
+		require.Equal(s.T(), modifiedSince, "")
+		rw.WriteHeader(200)
+		rw.Write(data)
+	}))
+	defer ts.Close()
+
+	recentTimestamp := "Thu, 25 Feb 2021 01:45:00 GMT"
+
+	header := make(http.Header)
+	header.Set("If-Modified-Since", recentTimestamp)
+	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
+	res := rw.Result()
+
+	require.Equal(s.T(), 200, res.StatusCode)
+}
+func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareMoreRecentLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		fileLastModified, _ := time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT")
+		modifiedSince := r.Header.Get("If-Modified-Since")
+		parsedModifiedSince, err := time.Parse(http.TimeFormat, modifiedSince)
+		require.Nil(s.T(), err)
+		require.True(s.T(), fileLastModified.Before(parsedModifiedSince))
+		rw.WriteHeader(304)
+	}))
+	defer ts.Close()
+
+	recentTimestamp := "Thu, 25 Feb 2021 01:45:00 GMT"
+
+	header := make(http.Header)
+	header.Set("If-Modified-Since", recentTimestamp)
+	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
+	res := rw.Result()
+
+	require.Equal(s.T(), 304, res.StatusCode)
+}
+func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifiedDisabled() {
+	config.LastModifiedEnabled = false
+	data := s.readTestFile("test1.png")
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		modifiedSince := r.Header.Get("If-Modified-Since")
+		require.Equal(s.T(), modifiedSince, "")
+		rw.WriteHeader(200)
+		rw.Write(data)
+	}))
+	defer ts.Close()
+
+	oldTimestamp := "Tue, 01 Oct 2013 17:31:00 GMT"
+
+	header := make(http.Header)
+	header.Set("If-Modified-Since", oldTimestamp)
+	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
+	res := rw.Result()
+
+	require.Equal(s.T(), 200, res.StatusCode)
+}
+func (s *ProcessingHandlerTestSuite) TestModifiedSinceReqCompareTooOldLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	data := s.readTestFile("test1.png")
+	ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+		fileLastModified, _ := time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT")
+		modifiedSince := r.Header.Get("If-Modified-Since")
+		parsedModifiedSince, err := time.Parse(http.TimeFormat, modifiedSince)
+		require.Nil(s.T(), err)
+		require.True(s.T(), fileLastModified.After(parsedModifiedSince))
+		rw.WriteHeader(200)
+		rw.Write(data)
+	}))
+	defer ts.Close()
+
+	oldTimestamp := "Tue, 01 Oct 2013 17:31:00 GMT"
+
+	header := make(http.Header)
+	header.Set("If-Modified-Since", oldTimestamp)
+	rw := s.send(fmt.Sprintf("/unsafe/plain/%s", ts.URL), header)
+	res := rw.Result()
+
+	require.Equal(s.T(), 200, res.StatusCode)
+}
 func TestProcessingHandler(t *testing.T) {
 func TestProcessingHandler(t *testing.T) {
 	suite.Run(t, new(ProcessingHandlerTestSuite))
 	suite.Run(t, new(ProcessingHandlerTestSuite))
 }
 }

+ 1 - 0
stream.go

@@ -37,6 +37,7 @@ var (
 		"Content-Encoding",
 		"Content-Encoding",
 		"Content-Range",
 		"Content-Range",
 		"Accept-Ranges",
 		"Accept-Ranges",
+		"Last-Modified",
 	}
 	}
 
 
 	streamBufPool = sync.Pool{
 	streamBufPool = sync.Pool{

+ 4 - 0
transport/azure/azure.go

@@ -123,6 +123,10 @@ func (t transport) RoundTrip(req *http.Request) (*http.Response, error) {
 		etag := string(*result.ETag)
 		etag := string(*result.ETag)
 		header.Set("ETag", etag)
 		header.Set("ETag", etag)
 	}
 	}
+	if config.LastModifiedEnabled && result.LastModified != nil {
+		lastModified := result.LastModified.Format(http.TimeFormat)
+		header.Set("Last-Modified", lastModified)
+	}
 
 
 	if resp := notmodified.Response(req, header); resp != nil {
 	if resp := notmodified.Response(req, header); resp != nil {
 		if result.Body != nil {
 		if result.Body != nil {

+ 47 - 3
transport/azure/azure_test.go

@@ -5,6 +5,7 @@ import (
 	"net/http/httptest"
 	"net/http/httptest"
 	"os"
 	"os"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/sirupsen/logrus"
 	"github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
@@ -16,9 +17,10 @@ import (
 type AzureTestSuite struct {
 type AzureTestSuite struct {
 	suite.Suite
 	suite.Suite
 
 
-	server    *httptest.Server
-	transport http.RoundTripper
-	etag      string
+	server       *httptest.Server
+	transport    http.RoundTripper
+	etag         string
+	lastModified time.Time
 }
 }
 
 
 func (s *AzureTestSuite) SetupSuite() {
 func (s *AzureTestSuite) SetupSuite() {
@@ -27,11 +29,13 @@ func (s *AzureTestSuite) SetupSuite() {
 	logrus.SetOutput(os.Stdout)
 	logrus.SetOutput(os.Stdout)
 
 
 	s.etag = "testetag"
 	s.etag = "testetag"
+	s.lastModified, _ = time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT")
 
 
 	s.server = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 	s.server = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		require.Equal(s.T(), "/test/foo/test.png", r.URL.Path)
 		require.Equal(s.T(), "/test/foo/test.png", r.URL.Path)
 
 
 		rw.Header().Set("Etag", s.etag)
 		rw.Header().Set("Etag", s.etag)
+		rw.Header().Set("Last-Modified", s.lastModified.Format(http.TimeFormat))
 		rw.WriteHeader(200)
 		rw.WriteHeader(200)
 		rw.Write(data)
 		rw.Write(data)
 	}))
 	}))
@@ -91,6 +95,46 @@ func (s *AzureTestSuite) TestRoundTripWithUpdatedETagReturns200() {
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 }
 }
 
 
+func (s *AzureTestSuite) TestRoundTripWithLastModifiedDisabledReturns200() {
+	config.LastModifiedEnabled = false
+	request, _ := http.NewRequest("GET", "abs://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+}
+
+func (s *AzureTestSuite) TestRoundTripWithLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	request, _ := http.NewRequest("GET", "abs://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+	require.Equal(s.T(), s.lastModified.Format(http.TimeFormat), response.Header.Get("Last-Modified"))
+}
+
+func (s *AzureTestSuite) TestRoundTripWithIfModifiedSinceReturns304() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "abs://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusNotModified, response.StatusCode)
+}
+
+func (s *AzureTestSuite) TestRoundTripWithUpdatedLastModifiedReturns200() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "abs://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Add(-24*time.Hour).Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusOK, response.StatusCode)
+}
 func TestAzureTransport(t *testing.T) {
 func TestAzureTransport(t *testing.T) {
 	suite.Run(t, new(AzureTestSuite))
 	suite.Run(t, new(AzureTestSuite))
 }
 }

+ 5 - 0
transport/fs/fs.go

@@ -78,6 +78,11 @@ func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error)
 			etag := BuildEtag(req.URL.Path, fi)
 			etag := BuildEtag(req.URL.Path, fi)
 			header.Set("ETag", etag)
 			header.Set("ETag", etag)
 		}
 		}
+
+		if config.LastModifiedEnabled {
+			lastModified := fi.ModTime().Format(http.TimeFormat)
+			header.Set("Last-Modified", lastModified)
+		}
 	}
 	}
 
 
 	if resp := notmodified.Response(req, header); resp != nil {
 	if resp := notmodified.Response(req, header); resp != nil {

+ 42 - 1
transport/fs/fs_test.go

@@ -5,6 +5,7 @@ import (
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
 	"github.com/stretchr/testify/suite"
@@ -17,6 +18,7 @@ type FsTestSuite struct {
 
 
 	transport http.RoundTripper
 	transport http.RoundTripper
 	etag      string
 	etag      string
+	modTime   time.Time
 }
 }
 
 
 func (s *FsTestSuite) SetupSuite() {
 func (s *FsTestSuite) SetupSuite() {
@@ -29,6 +31,7 @@ func (s *FsTestSuite) SetupSuite() {
 	require.Nil(s.T(), err)
 	require.Nil(s.T(), err)
 
 
 	s.etag = BuildEtag("/test1.png", fi)
 	s.etag = BuildEtag("/test1.png", fi)
+	s.modTime = fi.ModTime()
 	s.transport = New()
 	s.transport = New()
 }
 }
 
 
@@ -50,7 +53,6 @@ func (s *FsTestSuite) TestRoundTripWithETagEnabled() {
 	require.Equal(s.T(), 200, response.StatusCode)
 	require.Equal(s.T(), 200, response.StatusCode)
 	require.Equal(s.T(), s.etag, response.Header.Get("ETag"))
 	require.Equal(s.T(), s.etag, response.Header.Get("ETag"))
 }
 }
-
 func (s *FsTestSuite) TestRoundTripWithIfNoneMatchReturns304() {
 func (s *FsTestSuite) TestRoundTripWithIfNoneMatchReturns304() {
 	config.ETagEnabled = true
 	config.ETagEnabled = true
 
 
@@ -72,7 +74,46 @@ func (s *FsTestSuite) TestRoundTripWithUpdatedETagReturns200() {
 	require.Nil(s.T(), err)
 	require.Nil(s.T(), err)
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 }
 }
+func (s *FsTestSuite) TestRoundTripWithLastModifiedDisabledReturns200() {
+	config.LastModifiedEnabled = false
+	request, _ := http.NewRequest("GET", "local:///test1.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+}
+
+func (s *FsTestSuite) TestRoundTripWithLastModifiedEnabledReturns200() {
+	config.LastModifiedEnabled = true
+	request, _ := http.NewRequest("GET", "local:///test1.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+	require.Equal(s.T(), s.modTime.Format(http.TimeFormat), response.Header.Get("Last-Modified"))
+}
+
+func (s *FsTestSuite) TestRoundTripWithIfModifiedSinceReturns304() {
+	config.LastModifiedEnabled = true
 
 
+	request, _ := http.NewRequest("GET", "local:///test1.png", nil)
+	request.Header.Set("If-Modified-Since", s.modTime.Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusNotModified, response.StatusCode)
+}
+
+func (s *FsTestSuite) TestRoundTripWithUpdatedLastModifiedReturns200() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "local:///test1.png", nil)
+	request.Header.Set("If-Modified-Since", s.modTime.Add(-time.Minute).Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusOK, response.StatusCode)
+}
 func TestS3Transport(t *testing.T) {
 func TestS3Transport(t *testing.T) {
 	suite.Run(t, new(FsTestSuite))
 	suite.Run(t, new(FsTestSuite))
 }
 }

+ 7 - 2
transport/gcs/gcs.go

@@ -122,12 +122,17 @@ func (t transport) RoundTrip(req *http.Request) (*http.Response, error) {
 
 
 	// We haven't initialize reader yet, this means that we need non-ranged reader
 	// We haven't initialize reader yet, this means that we need non-ranged reader
 	if reader == nil {
 	if reader == nil {
-		if config.ETagEnabled {
+		if config.ETagEnabled || config.LastModifiedEnabled {
 			attrs, err := obj.Attrs(req.Context())
 			attrs, err := obj.Attrs(req.Context())
 			if err != nil {
 			if err != nil {
 				return handleError(req, err)
 				return handleError(req, err)
 			}
 			}
-			header.Set("ETag", attrs.Etag)
+			if config.ETagEnabled {
+				header.Set("ETag", attrs.Etag)
+			}
+			if config.LastModifiedEnabled {
+				header.Set("Last-Modified", attrs.Updated.Format(http.TimeFormat))
+			}
 		}
 		}
 
 
 		if resp := notmodified.Response(req, header); resp != nil {
 		if resp := notmodified.Response(req, header); resp != nil {

+ 44 - 3
transport/gcs/gcs_test.go

@@ -5,6 +5,7 @@ import (
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/fsouza/fake-gcs-server/fakestorage"
 	"github.com/fsouza/fake-gcs-server/fakestorage"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
@@ -30,15 +31,17 @@ func getFreePort() (int, error) {
 type GCSTestSuite struct {
 type GCSTestSuite struct {
 	suite.Suite
 	suite.Suite
 
 
-	server    *fakestorage.Server
-	transport http.RoundTripper
-	etag      string
+	server       *fakestorage.Server
+	transport    http.RoundTripper
+	etag         string
+	lastModified time.Time
 }
 }
 
 
 func (s *GCSTestSuite) SetupSuite() {
 func (s *GCSTestSuite) SetupSuite() {
 	noAuth = true
 	noAuth = true
 
 
 	// s.etag = "testetag"
 	// s.etag = "testetag"
+	s.lastModified, _ = time.Parse(http.TimeFormat, "Wed, 21 Oct 2015 07:28:00 GMT")
 
 
 	port, err := getFreePort()
 	port, err := getFreePort()
 	require.Nil(s.T(), err)
 	require.Nil(s.T(), err)
@@ -53,6 +56,7 @@ func (s *GCSTestSuite) SetupSuite() {
 					BucketName: "test",
 					BucketName: "test",
 					Name:       "foo/test.png",
 					Name:       "foo/test.png",
 					// Etag:       s.etag,
 					// Etag:       s.etag,
+					Updated: s.lastModified,
 				},
 				},
 				Content: make([]byte, 32),
 				Content: make([]byte, 32),
 			},
 			},
@@ -116,6 +120,43 @@ func (s *GCSTestSuite) TestRoundTripWithUpdatedETagReturns200() {
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 }
 }
 
 
+func (s *GCSTestSuite) TestRoundTripWithLastModifiedDisabledReturns200() {
+	config.LastModifiedEnabled = false
+	request, _ := http.NewRequest("GET", "gcs://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+}
+func (s *GCSTestSuite) TestRoundTripWithLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	request, _ := http.NewRequest("GET", "gcs://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+	require.Equal(s.T(), s.lastModified.Format(http.TimeFormat), response.Header.Get("Last-Modified"))
+}
+func (s *GCSTestSuite) TestRoundTripWithIfModifiedSinceReturns304() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "gcs://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusNotModified, response.StatusCode)
+}
+func (s *GCSTestSuite) TestRoundTripWithUpdatedLastModifiedReturns200() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "gcs://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Sicne", s.lastModified.Add(-24*time.Hour).Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusOK, response.StatusCode)
+}
 func TestGCSTransport(t *testing.T) {
 func TestGCSTransport(t *testing.T) {
 	suite.Run(t, new(GCSTestSuite))
 	suite.Run(t, new(GCSTestSuite))
 }
 }

+ 22 - 0
transport/notmodified/notmodified.go

@@ -2,6 +2,7 @@ package notmodified
 
 
 import (
 import (
 	"net/http"
 	"net/http"
+	"time"
 
 
 	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/config"
 )
 )
@@ -15,6 +16,27 @@ func Response(req *http.Request, header http.Header) *http.Response {
 			return response(req, header)
 			return response(req, header)
 		}
 		}
 	}
 	}
+	if config.LastModifiedEnabled {
+		lastModifiedRaw := header.Get("Last-Modified")
+		if len(lastModifiedRaw) == 0 {
+			return nil
+		}
+		ifModifiedSinceRaw := req.Header.Get("If-Modified-Since")
+		if len(ifModifiedSinceRaw) == 0 {
+			return nil
+		}
+		lastModified, err := time.Parse(http.TimeFormat, lastModifiedRaw)
+		if err != nil {
+			return nil
+		}
+		ifModifiedSince, err := time.Parse(http.TimeFormat, ifModifiedSinceRaw)
+		if err != nil {
+			return nil
+		}
+		if !ifModifiedSince.Before(lastModified) {
+			return response(req, header)
+		}
+	}
 
 
 	return nil
 	return nil
 }
 }

+ 14 - 3
transport/s3/s3.go

@@ -5,6 +5,7 @@ import (
 	"io"
 	"io"
 	http "net/http"
 	http "net/http"
 	"strings"
 	"strings"
+	"time"
 
 
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/aws/awserr"
@@ -64,9 +65,19 @@ func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error)
 
 
 	if r := req.Header.Get("Range"); len(r) != 0 {
 	if r := req.Header.Get("Range"); len(r) != 0 {
 		input.Range = aws.String(r)
 		input.Range = aws.String(r)
-	} else if config.ETagEnabled {
-		if ifNoneMatch := req.Header.Get("If-None-Match"); len(ifNoneMatch) > 0 {
-			input.IfNoneMatch = aws.String(ifNoneMatch)
+	} else {
+		if config.ETagEnabled {
+			if ifNoneMatch := req.Header.Get("If-None-Match"); len(ifNoneMatch) > 0 {
+				input.IfNoneMatch = aws.String(ifNoneMatch)
+			}
+		}
+		if config.LastModifiedEnabled {
+			if ifModifiedSince := req.Header.Get("If-Modified-Since"); len(ifModifiedSince) > 0 {
+				parsedIfModifiedSince, err := time.Parse(http.TimeFormat, ifModifiedSince)
+				if err == nil {
+					input.IfModifiedSince = &parsedIfModifiedSince
+				}
+			}
 		}
 		}
 	}
 	}
 
 

+ 48 - 3
transport/s3/s3_test.go

@@ -6,6 +6,7 @@ import (
 	"net/http/httptest"
 	"net/http/httptest"
 	"os"
 	"os"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/service/s3"
 	"github.com/aws/aws-sdk-go/service/s3"
@@ -20,9 +21,10 @@ import (
 type S3TestSuite struct {
 type S3TestSuite struct {
 	suite.Suite
 	suite.Suite
 
 
-	server    *httptest.Server
-	transport http.RoundTripper
-	etag      string
+	server       *httptest.Server
+	transport    http.RoundTripper
+	etag         string
+	lastModified time.Time
 }
 }
 
 
 func (s *S3TestSuite) SetupSuite() {
 func (s *S3TestSuite) SetupSuite() {
@@ -63,6 +65,7 @@ func (s *S3TestSuite) SetupSuite() {
 	defer obj.Body.Close()
 	defer obj.Body.Close()
 
 
 	s.etag = *obj.ETag
 	s.etag = *obj.ETag
+	s.lastModified = *obj.LastModified
 }
 }
 
 
 func (s *S3TestSuite) TearDownSuite() {
 func (s *S3TestSuite) TearDownSuite() {
@@ -110,6 +113,48 @@ func (s *S3TestSuite) TestRoundTripWithUpdatedETagReturns200() {
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 }
 }
 
 
+func (s *S3TestSuite) TestRoundTripWithLastModifiedDisabledReturns200() {
+	config.LastModifiedEnabled = false
+	request, _ := http.NewRequest("GET", "s3://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+}
+
+func (s *S3TestSuite) TestRoundTripWithLastModifiedEnabled() {
+	config.ETagEnabled = true
+	request, _ := http.NewRequest("GET", "s3://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+	require.Equal(s.T(), s.lastModified.Format(http.TimeFormat), response.Header.Get("Last-Modified"))
+}
+
+// gofakes3 doesn't support If-Modified-Since (yet?)
+func (s *S3TestSuite) TestRoundTripWithIfModifiedSinceReturns304() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "s3://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusNotModified, response.StatusCode)
+}
+
+func (s *S3TestSuite) TestRoundTripWithUpdatedLastModifiedReturns200() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "s3://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Add(-24*time.Hour).Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusOK, response.StatusCode)
+}
+
 func TestS3Transport(t *testing.T) {
 func TestS3Transport(t *testing.T) {
 	suite.Run(t, new(S3TestSuite))
 	suite.Run(t, new(S3TestSuite))
 }
 }

+ 9 - 3
transport/swift/swift.go

@@ -84,13 +84,19 @@ func (t transport) RoundTrip(req *http.Request) (resp *http.Response, err error)
 		if etag, ok := objectHeaders["Etag"]; ok {
 		if etag, ok := objectHeaders["Etag"]; ok {
 			header.Set("ETag", etag)
 			header.Set("ETag", etag)
 		}
 		}
+	}
 
 
-		if resp := notmodified.Response(req, header); resp != nil {
-			object.Close()
-			return resp, nil
+	if config.LastModifiedEnabled {
+		if lastModified, ok := objectHeaders["Last-Modified"]; ok {
+			header.Set("Last-Modified", lastModified)
 		}
 		}
 	}
 	}
 
 
+	if resp := notmodified.Response(req, header); resp != nil {
+		object.Close()
+		return resp, nil
+	}
+
 	for k, v := range objectHeaders {
 	for k, v := range objectHeaders {
 		header.Set(k, v)
 		header.Set(k, v)
 	}
 	}

+ 50 - 5
transport/swift/swift_test.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"net/http"
 	"net/http"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/ncw/swift/v2"
 	"github.com/ncw/swift/v2"
 	"github.com/ncw/swift/v2/swifttest"
 	"github.com/ncw/swift/v2/swifttest"
@@ -20,9 +21,10 @@ const (
 
 
 type SwiftTestSuite struct {
 type SwiftTestSuite struct {
 	suite.Suite
 	suite.Suite
-	server    *swifttest.SwiftServer
-	transport http.RoundTripper
-	etag      string
+	server       *swifttest.SwiftServer
+	transport    http.RoundTripper
+	etag         string
+	lastModified time.Time
 }
 }
 
 
 func (s *SwiftTestSuite) SetupSuite() {
 func (s *SwiftTestSuite) SetupSuite() {
@@ -71,10 +73,12 @@ func (s *SwiftTestSuite) setupTestFile() {
 	require.Nil(t, err)
 	require.Nil(t, err)
 
 
 	f.Close()
 	f.Close()
-
-	h, err := f.Headers()
+	// The Etag is written on file close; but Last-Modified is only available when we get the object again.
+	_, h, err := c.Object(ctx, testContainer, testObject)
 	require.Nil(t, err)
 	require.Nil(t, err)
 	s.etag = h["Etag"]
 	s.etag = h["Etag"]
+	s.lastModified, err = time.Parse(http.TimeFormat, h["Date"])
+	require.Nil(t, err)
 }
 }
 
 
 func (s *SwiftTestSuite) TearDownSuite() {
 func (s *SwiftTestSuite) TearDownSuite() {
@@ -140,6 +144,47 @@ func (s *SwiftTestSuite) TestRoundTripWithUpdatedETagReturns200() {
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 	require.Equal(s.T(), http.StatusOK, response.StatusCode)
 }
 }
 
 
+func (s *SwiftTestSuite) TestRoundTripWithLastModifiedDisabledReturns200() {
+	config.LastModifiedEnabled = false
+	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+}
+
+func (s *SwiftTestSuite) TestRoundTripWithLastModifiedEnabled() {
+	config.LastModifiedEnabled = true
+	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil)
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), 200, response.StatusCode)
+	require.Equal(s.T(), s.lastModified.Format(http.TimeFormat), response.Header.Get("Last-Modified"))
+}
+
+func (s *SwiftTestSuite) TestRoundTripWithIfModifiedSinceReturns304() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusNotModified, response.StatusCode)
+}
+
+func (s *SwiftTestSuite) TestRoundTripWithUpdatedLastModifiedReturns200() {
+	config.LastModifiedEnabled = true
+
+	request, _ := http.NewRequest("GET", "swift://test/foo/test.png", nil)
+	request.Header.Set("If-Modified-Since", s.lastModified.Add(-24*time.Hour).Format(http.TimeFormat))
+
+	response, err := s.transport.RoundTrip(request)
+	require.Nil(s.T(), err)
+	require.Equal(s.T(), http.StatusOK, response.StatusCode)
+}
+
 func TestSwiftTransport(t *testing.T) {
 func TestSwiftTransport(t *testing.T) {
 	suite.Run(t, new(SwiftTestSuite))
 	suite.Run(t, new(SwiftTestSuite))
 }
 }