diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 515c4df00..4bb80763e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,9 @@ name: codeql on: push: - branches: [ "master" ] + branches: [ "master", "develop" ] pull_request: - branches: [ "master" ] + branches: [ "master", "develop" ] schedule: - cron: '0 16 * * 1' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 238c13481..d553fcbb5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,7 +7,7 @@ name: docker on: push: - branches: [ master ] + branches: [ "master", "develop" ] tags: [ 'v*.*.*' ] jobs: diff --git a/README.md b/README.md index fabbc2565..a705d4df5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Test Status](https://github.com/cshum/imagor/workflows/test/badge.svg)](https://github.com/cshum/imagor/actions/workflows/test.yml) [![Coverage Status](https://coveralls.io/repos/github/cshum/imagor/badge.svg?branch=master)](https://coveralls.io/github/cshum/imagor?branch=master) [![Docker Hub](https://img.shields.io/badge/docker-shumc/imagor-blue.svg)](https://hub.docker.com/r/shumc/imagor/) -[![GitHub Container Registry](https://ghcr-badge.deta.dev/cshum/imagor/latest_tag?trim=major&label=ghcr.io&ignore=next,master&color=%23007ec6)](https://github.com/cshum/imagor/pkgs/container/imagor) +[![GitHub Container Registry](https://ghcr-badge.deta.dev/cshum/imagor/latest_tag?trim=major&label=ghcr.io&ignore=master,develop&color=%23007ec6)](https://github.com/cshum/imagor/pkgs/container/imagor) [![Go Reference](https://pkg.go.dev/badge/github.com/cshum/imagor.svg)](https://pkg.go.dev/github.com/cshum/imagor) imagor is a fast, secure image processing server and Go library. @@ -636,6 +636,8 @@ Usage of imagor: HTTP Loader base URL that prepends onto existing image path. This overrides the default scheme option. -http-loader-forward-headers string Forward request header to HTTP Loader request by csv e.g. User-Agent,Accept + -http-loader-override-response-headers string + Override HTTP Loader response header to image response by csv e.g. Cache-Control,Expires -http-loader-forward-client-headers Forward browser client request headers to HTTP Loader request -http-loader-insecure-skip-verify-transport diff --git a/blob.go b/blob.go index 6e5293210..ffa536800 100644 --- a/blob.go +++ b/blob.go @@ -52,7 +52,8 @@ type Blob struct { contentType string memory *memory - Stat *Stat + Header http.Header + Stat *Stat } // Stat Blob stat attributes diff --git a/config/config_test.go b/config/config_test.go index e56c3c86b..221ddea14 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -60,6 +60,7 @@ func TestBasic(t *testing.T) { "-imagor-cache-header-ttl", "169h", "-imagor-cache-header-swr", "167h", "-http-loader-insecure-skip-verify-transport", + "-http-loader-override-response-headers", "cache-control,content-type", "-http-loader-base-url", "https://www.example.com/foo.org", }) app := srv.App.(*imagor.Imagor) @@ -85,6 +86,7 @@ func TestBasic(t *testing.T) { httpLoader := app.Loaders[0].(*httploader.HTTPLoader) assert.True(t, httpLoader.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify) assert.Equal(t, "https://www.example.com/foo.org", httpLoader.BaseURL.String()) + assert.Equal(t, []string{"cache-control", "content-type"}, httpLoader.OverrideResponseHeaders) } func TestVersion(t *testing.T) { diff --git a/config/httpconfig.go b/config/httpconfig.go index 7a2ae527b..a64f24d66 100644 --- a/config/httpconfig.go +++ b/config/httpconfig.go @@ -14,6 +14,8 @@ func withHTTPLoader(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Opti var ( httpLoaderForwardHeaders = fs.String("http-loader-forward-headers", "", "Forward request header to HTTP Loader request by csv e.g. User-Agent,Accept") + httpLoaderOverrideResponseHeaders = fs.String("http-loader-override-response-headers", "", + "Override HTTP Loader response header to image response by csv e.g. Cache-Control,Expires") httpLoaderForwardClientHeaders = fs.Bool("http-loader-forward-client-headers", false, "Forward browser client request headers to HTTP Loader request") httpLoaderForwardAllHeaders = fs.Bool("http-loader-forward-all-headers", false, @@ -58,6 +60,7 @@ func withHTTPLoader(fs *flag.FlagSet, cb func() (*zap.Logger, bool)) imagor.Opti *httpLoaderForwardClientHeaders || *httpLoaderForwardAllHeaders), httploader.WithAccept(*httpLoaderAccept), httploader.WithForwardHeaders(*httpLoaderForwardHeaders), + httploader.WithOverrideResponseHeaders(*httpLoaderOverrideResponseHeaders), httploader.WithAllowedSources(*httpLoaderAllowedSources), httploader.WithAllowedSourceRegexps(*httpLoaderAllowedSourceRegexp), httploader.WithMaxAllowedSize(*httpLoaderMaxAllowedSize), diff --git a/imagor.go b/imagor.go index 344de2a9d..52170504e 100644 --- a/imagor.go +++ b/imagor.go @@ -22,7 +22,7 @@ import ( ) // Version imagor version -const Version = "1.4.10" +const Version = "1.4.11" // Loader image loader interface type Loader interface { @@ -203,6 +203,11 @@ func (app *Imagor) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Imagor-Raw") != "" { w.Header().Set("Content-Security-Policy", "script-src 'none'") } + if h := blob.Header; h != nil { + for key := range h { + w.Header().Set(key, h.Get(key)) + } + } if checkStatNotModified(w, r, blob.Stat) { w.WriteHeader(http.StatusNotModified) return @@ -388,6 +393,9 @@ func (app *Imagor) Do(r *http.Request, p imagorpath.Params) (blob *Blob, err err for _, processor := range app.Processors { b, e := checkBlob(processor.Process(ctx, blob, forwardP, load)) if !isBlobEmpty(b) { + if blob != nil && blob.Header != nil && b.Header == nil { + b.Header = blob.Header // forward blob Header + } blob = b // forward Blob to next processor if exists } if e == nil { diff --git a/imagor_test.go b/imagor_test.go index 6df6fe10b..11c9c48fb 100644 --- a/imagor_test.go +++ b/imagor_test.go @@ -331,6 +331,35 @@ func TestWithRaw(t *testing.T) { assert.Equal(t, "bar", w.Header().Get("Content-Type")) } +func TestWithOverrideHeader(t *testing.T) { + app := New( + WithDebug(true), + WithUnsafe(true), + WithLogger(zap.NewExample()), + WithLoaders(loaderFunc(func(r *http.Request, image string) (*Blob, error) { + blob := NewBlobFromBytes([]byte("foo")) + blob.SetContentType("bar") + blob.Header = make(http.Header) + blob.Header.Set("Content-Type", "tada") + blob.Header.Set("Foo", "bar") + blob.Header.Set("asdf", "fghj") + return blob, nil + })), + WithProcessors(processorFunc(func(ctx context.Context, blob *Blob, p imagorpath.Params, load LoadFunc) (*Blob, error) { + out := NewBlobFromBytes([]byte("processed")) + out.SetContentType("boom") + return out, nil + })), + ) + w := httptest.NewRecorder() + app.ServeHTTP(w, httptest.NewRequest( + http.MethodGet, "https://example.com/unsafe/filters:fill(red)/gopher.png", nil)) + assert.Equal(t, 200, w.Code) + assert.Equal(t, "processed", w.Body.String()) + assert.Equal(t, "tada", w.Header().Get("Content-Type")) + assert.Equal(t, "fghj", w.Header().Get("ASDF")) +} + func TestNewBlobFromPathNotFound(t *testing.T) { loader := loaderFunc(func(r *http.Request, image string) (*Blob, error) { return NewBlobFromFile("./non-exists-path"), nil diff --git a/loader/httploader/httploader.go b/loader/httploader/httploader.go index 3685bcec7..ae05bfc88 100644 --- a/loader/httploader/httploader.go +++ b/loader/httploader/httploader.go @@ -63,6 +63,9 @@ type HTTPLoader struct { // OverrideHeaders override image request headers OverrideHeaders map[string]string + // OverrideResponseHeaders override image response header from HTTP Loader response + OverrideResponseHeaders []string + // AllowedSources list of sources allowed to load from AllowedSources []AllowedSource @@ -204,6 +207,14 @@ func (h *HTTPLoader) Get(r *http.Request, image string) (*imagor.Blob, error) { } once.Do(func() { blob.SetContentType(resp.Header.Get("Content-Type")) + if len(h.OverrideResponseHeaders) > 0 { + blob.Header = make(http.Header) + for _, key := range h.OverrideResponseHeaders { + if val := resp.Header.Get(key); val != "" { + blob.Header.Set(key, val) + } + } + } }) body := resp.Body size, _ := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) diff --git a/loader/httploader/httploader_test.go b/loader/httploader/httploader_test.go index bc5758f36..debcd1bca 100644 --- a/loader/httploader/httploader_test.go +++ b/loader/httploader/httploader_test.go @@ -23,7 +23,7 @@ func (t testTransport) RoundTrip(r *http.Request) (w *http.Response, err error) w = &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(res)), - Header: map[string][]string{}, + Header: make(http.Header), } w.Header.Set("Content-Type", "image/jpeg") return @@ -48,6 +48,7 @@ type test struct { name string target string result string + header map[string]string err string } @@ -80,6 +81,11 @@ func doTests(t *testing.T, loader imagor.Loader, tests []test) { } assert.Equal(t, tt.err, msg) } + if tt.header != nil { + for key, val := range tt.header { + assert.Equal(t, val, b.Header.Get(key)) + } + } }) } } @@ -492,6 +498,31 @@ func TestWithForwardHeadersOverrideUserAgent(t *testing.T) { }) } +func TestWithOverrideResponseHeader(t *testing.T) { + doTests(t, New( + WithTransport(roundTripFunc(func(r *http.Request) (w *http.Response, err error) { + res := &http.Response{ + StatusCode: http.StatusOK, + Header: map[string][]string{}, + Body: io.NopCloser(strings.NewReader("ok")), + } + res.Header.Set("Content-Type", "image/jpeg") + res.Header.Set("Foo", "Bar") + return res, nil + })), + WithOverrideResponseHeaders("foo"), + ), []test{ + { + name: "user agent", + target: "https://foo.bar/baz", + result: "ok", + header: map[string]string{ + "Foo": "Bar", + }, + }, + }) +} + func TestWithForwardClientHeaders(t *testing.T) { doTests(t, New( WithTransport(roundTripFunc(func(r *http.Request) (w *http.Response, err error) { diff --git a/loader/httploader/option.go b/loader/httploader/option.go index 84adfe91a..0a0cc52dd 100644 --- a/loader/httploader/option.go +++ b/loader/httploader/option.go @@ -59,6 +59,21 @@ func WithForwardHeaders(headers ...string) Option { } } +// WithOverrideResponseHeaders with override selected response headers option +func WithOverrideResponseHeaders(headers ...string) Option { + return func(h *HTTPLoader) { + for _, raw := range headers { + splits := strings.Split(raw, ",") + for _, header := range splits { + header = strings.TrimSpace(header) + if len(header) > 0 { + h.OverrideResponseHeaders = append(h.OverrideResponseHeaders, header) + } + } + } + } +} + // WithForwardClientHeaders with forward browser request headers option func WithForwardClientHeaders(enabled bool) Option { return func(h *HTTPLoader) { diff --git a/testdata/golden/fit-in/100x100/filters%3Afill%28none%29/2bands.png b/testdata/golden/fit-in/100x100/filters%3Afill%28none%29/2bands.png new file mode 100644 index 000000000..63be6204d Binary files /dev/null and b/testdata/golden/fit-in/100x100/filters%3Afill%28none%29/2bands.png differ diff --git a/testdata/golden/fit-in/filters%3Alabel%28imagor%2C-1%2C0%2C50%29/2bands.png b/testdata/golden/fit-in/filters%3Alabel%28imagor%2C-1%2C0%2C50%29/2bands.png new file mode 100644 index 000000000..9894ae2e4 Binary files /dev/null and b/testdata/golden/fit-in/filters%3Alabel%28imagor%2C-1%2C0%2C50%29/2bands.png differ diff --git a/vips/filter.go b/vips/filter.go index c938ad123..155e6789e 100644 --- a/vips/filter.go +++ b/vips/filter.go @@ -215,6 +215,11 @@ func (v *Processor) fill(ctx context.Context, img *Image, w, h int, pLeft, pTop, } } if isTransparent { + if img.Bands() < 3 { + if err = img.ToColorSpace(InterpretationSRGB); err != nil { + return + } + } if err = img.AddAlpha(); err != nil { return } @@ -380,7 +385,11 @@ func label(_ context.Context, img *Image, _ imagor.LoadFunc, args ...string) (er font = args[6] } } - // make sure band equals 4 + if img.Bands() < 3 { + if err = img.ToColorSpace(InterpretationSRGB); err != nil { + return + } + } if err = img.AddAlpha(); err != nil { return } diff --git a/vips/processor_test.go b/vips/processor_test.go index 4e3294ba1..d9d19f1c9 100644 --- a/vips/processor_test.go +++ b/vips/processor_test.go @@ -125,6 +125,7 @@ func TestProcessor(t *testing.T) { {name: "crop-percent stretch top flip", path: "0.006120x0.008993:1.0x1.0/stretch/100x200/filters:brightness(-20):contrast(50):rgb(10,-50,30):fill(black)/gopher.png"}, {name: "padding rotation fill blur grayscale", path: "/fit-in/200x210/20x20/filters:rotate(90):rotate(270):rotate(180):fill(blur):grayscale()/gopher.png"}, {name: "fill round_corner", path: "fit-in/0x210/filters:fill(yellow):round_corner(40,60,green)/gopher.png"}, + {name: "grayscale fill none", path: "fit-in/100x100/filters:fill(none)/2bands.png", checkTypeOnly: true}, {name: "trim alpha", path: "trim/find_trim_alpha.png"}, {name: "trim with crop", path: "trim:bottom-right/50x50:0x0/find_trim.png"}, {name: "trim right", path: "trim:bottom-right/500x500/filters:strip_exif():upscale():no_upscale()/find_trim.png"}, @@ -183,6 +184,7 @@ func TestProcessor(t *testing.T) { {name: "label float", path: "fit-in/300x200/10x10/filters:fill(yellow):label(IMAGOR,-0.15,0.1,30,red,30)/gopher-front.png", arm64Golden: true}, {name: "label animated", path: "fit-in/150x200/10x00:10x50/filters:fill(yellow):label(IMAGOR,center,-30,25,black)/dancing-banana.gif", arm64Golden: true}, {name: "label animated with font", path: "fit-in/150x200/10x00:10x50/filters:fill(cyan):label(IMAGOR,center,-30,25,white,0,monospace)/dancing-banana.gif", arm64Golden: true}, + {name: "label grayscale", path: "fit-in/filters:label(imagor,-1,0,50)/2bands.png", checkTypeOnly: true}, {name: "strip exif", path: "filters:strip_exif()/Canon_40D.jpg"}, {name: "bmp 24bit", path: "100x100/bmp_24.bmp"}, {name: "bmp 8bit", path: "100x100/lena_gray.bmp"},