├── third_party ├── http │ ├── README │ ├── LICENSE │ └── server.go └── envy │ ├── go.mod │ ├── README.md │ ├── LICENSE │ └── envy.go ├── internal ├── transport │ ├── README │ └── transport.go ├── gcscache │ └── gcscache.go └── s3cache │ └── s3cache.go ├── .github ├── dependabot.yml └── workflows │ ├── linter.yml │ ├── docker-tags.yml │ ├── docker-latest.yml │ ├── tests.yml │ └── codeql-analysis.yml ├── etc ├── debian │ ├── etc-default-imageproxy │ └── etc-initd-imageproxy ├── imageproxy.service └── imageproxy.conf ├── cache_test.go ├── Dockerfile ├── .golangci.yml ├── cache.go ├── docs ├── contributing.md ├── url-signing.md ├── plugin-design.md └── changelog.md ├── metrics.go ├── go.mod ├── cmd ├── imageproxy-sign │ ├── main.go │ └── main_test.go └── imageproxy │ └── main.go ├── data_test.go ├── transform.go ├── LICENSE ├── data.go ├── imageproxy.go ├── imageproxy_test.go ├── README.md └── transform_test.go /third_party/http/README: -------------------------------------------------------------------------------- 1 | Package http is based on a copy of net/http. 2 | -------------------------------------------------------------------------------- /internal/transport/README: -------------------------------------------------------------------------------- 1 | A wrapper over AIA http.Transport with support for denied hosts. 2 | -------------------------------------------------------------------------------- /third_party/envy/go.mod: -------------------------------------------------------------------------------- 1 | module willnorris.com/go/imageproxy/third_party/envy 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /third_party/envy/README.md: -------------------------------------------------------------------------------- 1 | envy is a copy of https://github.com/jamiealquiza/envy without the cobra 2 | support. 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | assignees: 9 | - willnorris 10 | -------------------------------------------------------------------------------- /etc/debian/etc-default-imageproxy: -------------------------------------------------------------------------------- 1 | #/etc/default/imageproxy 2 | 3 | DAEMON_USER=www-data 4 | ADDR="localhost:4593" 5 | LOG_DIR=/var/log/imageproxy 6 | CACHE_DIR=/var/cache/imageproxy 7 | #CACHE_SIZE=1024 #MB 8 | #ALLOWED_REMOTE_HOSTS="" 9 | -------------------------------------------------------------------------------- /etc/imageproxy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Image Proxy 3 | 4 | [Service] 5 | User=www-data 6 | ExecStart=/usr/local/bin/imageproxy \ 7 | -addr localhost:4593 \ 8 | -cache memory -cache /var/cache/imageproxy \ 9 | Restart=on-abort 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /etc/imageproxy.conf: -------------------------------------------------------------------------------- 1 | description "Image Proxy server" 2 | author "Will Norris " 3 | 4 | start on (net-device-up) 5 | stop on runlevel [!2345] 6 | 7 | respawn 8 | exec start-stop-daemon --start -c www-data --exec /usr/local/bin/imageproxy -- \ 9 | -addr localhost:4593 \ 10 | -cache memory -cache /var/cache/imageproxy \ 11 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: linter 3 | 4 | jobs: 5 | lint: 6 | strategy: 7 | matrix: 8 | go-version: [1.x] 9 | platform: [ubuntu-latest] 10 | runs-on: ${{ matrix.platform }} 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: golangci-lint 16 | uses: golangci/golangci-lint-action@v2 17 | with: 18 | version: v1.31 19 | -------------------------------------------------------------------------------- /.github/workflows/docker-tags.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '*' 5 | name: docker-publish-tags 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: docker/build-push-action@v1 14 | with: 15 | username: ${{ secrets.DOCKER_USERNAME }} 16 | password: ${{ secrets.DOCKER_PASSWORD }} 17 | repository: willnorris/imageproxy 18 | tag_with_ref: true 19 | -------------------------------------------------------------------------------- /.github/workflows/docker-latest.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - 'main' 5 | name: docker-publish-latest 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: docker/build-push-action@v1 14 | with: 15 | username: ${{ secrets.DOCKER_USERNAME }} 16 | password: ${{ secrets.DOCKER_PASSWORD }} 17 | repository: willnorris/imageproxy 18 | tags: latest 19 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | import "testing" 7 | 8 | func TestNopCache(t *testing.T) { 9 | data, ok := NopCache.Get("foo") 10 | if data != nil { 11 | t.Errorf("NopCache.Get returned non-nil data") 12 | } 13 | if ok != false { 14 | t.Errorf("NopCache.Get returned ok = true, should always be false.") 15 | } 16 | 17 | // nothing to test on these methods other than to verify they exist 18 | NopCache.Set("", []byte{}) 19 | NopCache.Delete("") 20 | } 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16 as build 2 | LABEL maintainer="Will Norris " 3 | 4 | RUN useradd -u 1001 go 5 | 6 | WORKDIR /app 7 | 8 | COPY go.mod go.sum ./ 9 | COPY third_party/envy/go.mod ./third_party/envy/ 10 | RUN go mod download 11 | 12 | COPY . . 13 | 14 | RUN CGO_ENABLED=0 GOOS=linux go build -v ./cmd/imageproxy 15 | 16 | FROM scratch 17 | 18 | COPY --from=build /etc/passwd /etc/passwd 19 | COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo 20 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 21 | COPY --from=build /app/imageproxy /app/imageproxy 22 | 23 | USER go 24 | 25 | CMD ["-addr", "0.0.0.0:8080"] 26 | ENTRYPOINT ["/app/imageproxy"] 27 | 28 | EXPOSE 8080 29 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - dogsled 4 | - dupl 5 | - goimports 6 | - gosec 7 | - misspell 8 | - nakedret 9 | - stylecheck 10 | - unconvert 11 | - unparam 12 | - whitespace 13 | 14 | issues: 15 | exclude-rules: 16 | # Some cache implementations use md5 hashes for cached filenames. There is 17 | # a slight risk of cache poisoning if an attacker could construct a URL 18 | # with the same hash, but it would also need to be allowed by the proxies 19 | # security settings. Changing these to a more secure hash algorithm would 20 | # result in 100% cache misses when users upgrade. For now, just leave these 21 | # alone. 22 | - path: internal/.*cache 23 | linters: gosec 24 | text: G(401|501) 25 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | // The Cache interface defines a cache for storing arbitrary data. The 7 | // interface is designed to align with httpcache.Cache. 8 | type Cache interface { 9 | // Get retrieves the cached data for the provided key. 10 | Get(key string) (data []byte, ok bool) 11 | 12 | // Set caches the provided data. 13 | Set(key string, data []byte) 14 | 15 | // Delete deletes the cached data at the specified key. 16 | Delete(key string) 17 | } 18 | 19 | // NopCache provides a no-op cache implementation that doesn't actually cache anything. 20 | var NopCache = new(nopCache) 21 | 22 | type nopCache struct{} 23 | 24 | func (c nopCache) Get(string) ([]byte, bool) { return nil, false } 25 | func (c nopCache) Set(string, []byte) {} 26 | func (c nopCache) Delete(string) {} 27 | -------------------------------------------------------------------------------- /third_party/envy/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Jamie Alquiza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to imageproxy 2 | 3 | ## Types of contributions 4 | 5 | Simple bug fixes for existing functionality are always welcome. In many cases, 6 | it may be helpful to include a reproducible sample case that demonstrates the 7 | bug being fixed. 8 | 9 | For new functionality, it's general best to open an issue first to discuss it. 10 | 11 | ## Reporting issues 12 | 13 | Bugs, feature requests, and development-related questions should be directed to 14 | the [GitHub issue tracker](https://github.com/willnorris/imageproxy/issues). 15 | If reporting a bug, please try and provide as much context as possible such as 16 | what version of imageproxy you're running, what configuration options, specific 17 | remote URLs that exhibit issues, and anything else that might be relevant to 18 | the bug. For feature requests, please explain what you're trying to do, and 19 | how the requested feature would help you do that. 20 | 21 | Security related bugs can either be reported in the issue tracker, or if they 22 | are more sensitive, emailed to . 23 | 24 | ## Code Style and Tests 25 | 26 | Go code should follow general best practices, such as using go fmt, go lint, and 27 | go vet (this is enforced by our continuous integration setup). Tests should 28 | always be included where possible, especially for bug fixes in order to prevent 29 | regressions. 30 | -------------------------------------------------------------------------------- /third_party/envy/envy.go: -------------------------------------------------------------------------------- 1 | // Package envy automatically exposes environment 2 | // variables for all of your flags. 3 | package envy 4 | 5 | import ( 6 | "flag" 7 | "fmt" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | // Parse takes a prefix string and exposes environment variables 13 | // for all flags in the default FlagSet (flag.CommandLine) in the 14 | // form of PREFIX_FLAGNAME. 15 | func Parse(p string) { 16 | update(p, flag.CommandLine) 17 | } 18 | 19 | // update takes a prefix string p and *flag.FlagSet. Each flag 20 | // in the FlagSet is exposed as an upper case environment variable 21 | // prefixed with p. Any flag that was not explicitly set by a user 22 | // is updated to the environment variable, if set. 23 | func update(p string, fs *flag.FlagSet) { 24 | // Build a map of explicitly set flags. 25 | set := map[string]interface{}{} 26 | fs.Visit(func(f *flag.Flag) { 27 | set[f.Name] = nil 28 | }) 29 | 30 | fs.VisitAll(func(f *flag.Flag) { 31 | // Create an env var name 32 | // based on the supplied prefix. 33 | envVar := fmt.Sprintf("%s_%s", p, strings.ToUpper(f.Name)) 34 | envVar = strings.Replace(envVar, "-", "_", -1) 35 | 36 | // Update the Flag.Value if the 37 | // env var is non "". 38 | if val := os.Getenv(envVar); val != "" { 39 | // Update the value if it hasn't 40 | // already been set. 41 | if _, defined := set[f.Name]; !defined { 42 | fs.Set(f.Name, val) 43 | } 44 | } 45 | 46 | // Append the env var to the 47 | // Flag.Usage field. 48 | f.Usage = fmt.Sprintf("%s [%s]", f.Usage, envVar) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | import ( 7 | "github.com/prometheus/client_golang/prometheus" 8 | ) 9 | 10 | var ( 11 | metricServedFromCache = prometheus.NewCounter( 12 | prometheus.CounterOpts{ 13 | Namespace: "imageproxy", 14 | Name: "requests_served_from_cache_total", 15 | Help: "Number of requests served from cache.", 16 | }) 17 | metricTransformationDuration = prometheus.NewSummary(prometheus.SummaryOpts{ 18 | Namespace: "imageproxy", 19 | Name: "transformation_duration_seconds", 20 | Help: "Time taken for image transformations in seconds.", 21 | }) 22 | metricRemoteErrors = prometheus.NewCounter(prometheus.CounterOpts{ 23 | Namespace: "imageproxy", 24 | Name: "remote_fetch_errors_total", 25 | Help: "Total remote image fetch errors", 26 | }) 27 | metricRequestDuration = prometheus.NewSummary(prometheus.SummaryOpts{ 28 | Namespace: "http", 29 | Name: "request_duration_seconds", 30 | Help: "Request response times", 31 | }) 32 | metricRequestsInFlight = prometheus.NewGauge(prometheus.GaugeOpts{ 33 | Namespace: "http", 34 | Name: "requests_in_flight", 35 | Help: "Number of requests in flight", 36 | }) 37 | ) 38 | 39 | func init() { 40 | prometheus.MustRegister(metricTransformationDuration) 41 | prometheus.MustRegister(metricServedFromCache) 42 | prometheus.MustRegister(metricRemoteErrors) 43 | prometheus.MustRegister(metricRequestDuration) 44 | prometheus.MustRegister(metricRequestsInFlight) 45 | } 46 | -------------------------------------------------------------------------------- /third_party/http/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: tests 3 | env: 4 | GO111MODULE: on 5 | 6 | jobs: 7 | test: 8 | strategy: 9 | matrix: 10 | go-version: 11 | # support the two most recent major go versions 12 | - 1.x 13 | - 1.15.x 14 | platform: [ubuntu-latest, windows-latest] 15 | include: 16 | # minimum go version that works. This is not necessarily supported in 17 | # any way, and will be bumped up without notice as needed. But it at 18 | # least lets us know what go version should work. 19 | - go-version: 1.11 20 | platform: ubuntu-latest 21 | 22 | # only update test coverage stats with most recent go version on linux 23 | - go-version: 1.x 24 | platform: ubuntu-latest 25 | update-coverage: true 26 | runs-on: ${{ matrix.platform }} 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | 31 | - uses: actions/setup-go@v1 32 | with: 33 | go-version: ${{ matrix.go-version }} 34 | - run: go version 35 | 36 | - name: Cache go modules 37 | uses: actions/cache@v2 38 | with: 39 | path: ~/go/pkg/mod 40 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 41 | restore-keys: ${{ runner.os }}-go- 42 | 43 | - name: Run go test 44 | run: go test -v -race -coverprofile coverage.txt -covermode atomic ./... 45 | 46 | - name: Upload coverage to Codecov 47 | if: ${{ matrix.update-coverage }} 48 | uses: codecov/codecov-action@v1 49 | timeout-minutes: 2 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module willnorris.com/go/imageproxy 2 | 3 | require ( 4 | cloud.google.com/go v0.76.0 // indirect 5 | cloud.google.com/go/storage v1.13.0 6 | github.com/Azure/azure-sdk-for-go v51.1.0+incompatible // indirect 7 | github.com/Azure/go-autorest/autorest v0.11.18 // indirect 8 | github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect 9 | github.com/PaulARoy/azurestoragecache v0.0.0-20170906084534-3c249a3ba788 10 | github.com/aws/aws-sdk-go v1.37.10 11 | github.com/die-net/lrucache v0.0.0-20190707192454-883874fe3947 12 | github.com/disintegration/imaging v1.6.2 13 | github.com/dnaeon/go-vcr v1.0.1 // indirect 14 | github.com/fcjr/aia-transport-go v1.2.1 15 | github.com/gomodule/redigo v2.0.0+incompatible 16 | github.com/gorilla/mux v1.8.0 17 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 18 | github.com/jamiealquiza/envy v1.1.0 19 | github.com/muesli/smartcrop v0.3.0 20 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 21 | github.com/peterbourgon/diskv v0.0.0-20171120014656-2973218375c3 22 | github.com/prometheus/client_golang v1.9.0 23 | github.com/prometheus/procfs v0.6.0 // indirect 24 | github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd 25 | github.com/satori/go.uuid v0.0.0-20180103174451-36e9d2ebbde5 // indirect 26 | go.opencensus.io v0.22.6 // indirect 27 | golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect 28 | golang.org/x/image v0.0.0-20201208152932-35266b937fa6 29 | golang.org/x/oauth2 v0.0.0-20210210192628-66670185b0cd // indirect 30 | google.golang.org/api v0.39.0 // indirect 31 | google.golang.org/genproto v0.0.0-20210211154401-3a9a48ddfd6c // indirect 32 | willnorris.com/go/gifresize v1.0.0 33 | ) 34 | 35 | // local copy of envy package without cobra support 36 | replace github.com/jamiealquiza/envy => ./third_party/envy 37 | 38 | go 1.13 39 | -------------------------------------------------------------------------------- /etc/debian/etc-initd-imageproxy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ### BEGIN INIT INFO 4 | # Provides: imageproxy 5 | # Required-Start: $local_fs $remote_fs $network 6 | # Required-Stop: $local_fs $remote_fs $network 7 | # Default-Start: 2 3 4 5 8 | # Default-Stop: 0 1 6 9 | # Short-Description: starts the imageproxy web server 10 | # Description: starts imageproxy using start-stop-daemon 11 | ### END INIT INFO 12 | 13 | PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/go/bin 14 | DAEMON=/usr/local/go/bin/imageproxy 15 | DAEMON_OPTS= 16 | NAME=imageproxy 17 | DESC=imageproxy 18 | 19 | test -x $DAEMON || exit 0 20 | 21 | #set -e 22 | 23 | . /lib/lsb/init-functions 24 | 25 | case "$1" in 26 | start) 27 | . /etc/default/imageproxy 28 | 29 | test -n "$ADDR" && DAEMON_OPTS+=" -addr=$ADDR" 30 | test -n "$CACHE" && DAEMON_OPTS+=" -cache=$CACHE" 31 | test -n "$LOG_DIR" && DAEMON_OPTS+=" -log_dir=$LOG_DIR" 32 | test -n "$ALLOWED_REMOTE_HOSTS" && DAEMON_OPTS+=" -allowHosts=$ALLOWED_REMOTE_HOSTS" 33 | 34 | echo -n "Starting $NAME: " 35 | start-stop-daemon --start --quiet --pidfile /var/run/$NAME.pid \ 36 | --chuid $DAEMON_USER --background --make-pidfile \ 37 | --exec $DAEMON -- $DAEMON_OPTS || true 38 | sleep 1 39 | echo "." 40 | ;; 41 | 42 | stop) 43 | echo -n "Stopping $NAME: " 44 | start-stop-daemon --stop --quiet --pidfile /var/run/$NAME.pid \ 45 | --exec $DAEMON || true 46 | echo "." 47 | ;; 48 | 49 | restart|force-reload) 50 | echo -n "Restarting $NAME: " 51 | start-stop-daemon --stop --quiet --pidfile \ 52 | /var/run/$NAME.pid --exec $DAEMON || true 53 | sleep 1 54 | start-stop-daemon --start --quiet --pidfile \ 55 | /var/run/$NAME.pid --exec $DAEMON -- $DAEMON_OPTS || true 56 | echo "." 57 | ;; 58 | 59 | status) 60 | status_of_proc -p /var/run/$NAME.pid "$DAEMON" imageproxy && exit 0 || exit $? 61 | ;; 62 | *) 63 | echo "Usage: $NAME {start|stop|restart|status}" >&2 64 | exit 1 65 | ;; 66 | esac 67 | 68 | exit 0 69 | -------------------------------------------------------------------------------- /internal/gcscache/gcscache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Package gcscache provides an httpcache.Cache implementation that stores 5 | // cached values on Google Cloud Storage. 6 | package gcscache 7 | 8 | import ( 9 | "context" 10 | "crypto/md5" 11 | "encoding/hex" 12 | "io" 13 | "io/ioutil" 14 | "log" 15 | "path" 16 | 17 | "cloud.google.com/go/storage" 18 | ) 19 | 20 | var ctx = context.Background() 21 | 22 | type cache struct { 23 | bucket *storage.BucketHandle 24 | prefix string 25 | } 26 | 27 | func (c *cache) Get(key string) ([]byte, bool) { 28 | r, err := c.object(key).NewReader(ctx) 29 | if err != nil { 30 | if err != storage.ErrObjectNotExist { 31 | log.Printf("error reading from gcs: %v", err) 32 | } 33 | return nil, false 34 | } 35 | defer r.Close() 36 | 37 | value, err := ioutil.ReadAll(r) 38 | if err != nil { 39 | log.Printf("error reading from gcs: %v", err) 40 | return nil, false 41 | } 42 | 43 | return value, true 44 | } 45 | 46 | func (c *cache) Set(key string, value []byte) { 47 | w := c.object(key).NewWriter(ctx) 48 | if _, err := w.Write(value); err != nil { 49 | log.Printf("error writing to gcs: %v", err) 50 | } 51 | if err := w.Close(); err != nil { 52 | log.Printf("error closing gcs object writer: %v", err) 53 | } 54 | } 55 | 56 | func (c *cache) Delete(key string) { 57 | if err := c.object(key).Delete(ctx); err != nil { 58 | log.Printf("error deleting gcs object: %v", err) 59 | } 60 | } 61 | 62 | func (c *cache) object(key string) *storage.ObjectHandle { 63 | name := path.Join(c.prefix, keyToFilename(key)) 64 | return c.bucket.Object(name) 65 | } 66 | 67 | func keyToFilename(key string) string { 68 | h := md5.New() 69 | _, _ = io.WriteString(h, key) 70 | return hex.EncodeToString(h.Sum(nil)) 71 | } 72 | 73 | // New constructs a Cache storing files in the specified GCS bucket. If prefix 74 | // is not empty, objects will be prefixed with that path. Credentials should 75 | // be specified using one of the mechanisms supported for Application Default 76 | // Credentials (see https://cloud.google.com/docs/authentication/production) 77 | func New(bucket, prefix string) (*cache, error) { 78 | client, err := storage.NewClient(ctx) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return &cache{ 84 | prefix: prefix, 85 | bucket: client.Bucket(bucket), 86 | }, nil 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: '0 1 * * 6' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['go'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | # If you wish to specify custom queries, you can do so here or in a config file. 45 | # By default, queries listed here will override any specified in a config file. 46 | # Prefix the list here with "+" to use these queries and those in the config file. 47 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 48 | 49 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 50 | # If this step fails, then you should remove it and run the build manually (see below) 51 | - name: Autobuild 52 | uses: github/codeql-action/autobuild@v1 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 https://git.io/JvXDl 56 | 57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 58 | # and modify them (or add more) to build your code if your project 59 | # uses a compiled language 60 | 61 | #- run: | 62 | # make bootstrap 63 | # make release 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v1 67 | -------------------------------------------------------------------------------- /cmd/imageproxy-sign/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // The imageproxy-sign tool creates signature values for a provided URL and 5 | // signing key. 6 | package main 7 | 8 | import ( 9 | "crypto/hmac" 10 | "crypto/sha256" 11 | "encoding/base64" 12 | "errors" 13 | "flag" 14 | "fmt" 15 | "io/ioutil" 16 | "net/http" 17 | "net/url" 18 | "os" 19 | "strings" 20 | 21 | "willnorris.com/go/imageproxy" 22 | ) 23 | 24 | var signingKey = flag.String("key", "@/etc/imageproxy.key", "signing key, or file containing key prefixed with '@'") 25 | var urlOnly = flag.Bool("url", false, "only sign the URL value, do not include options") 26 | 27 | func main() { 28 | flag.Parse() 29 | u := flag.Arg(0) 30 | 31 | sig, err := sign(*signingKey, u, *urlOnly) 32 | if err != nil { 33 | fmt.Println(err) 34 | os.Exit(1) 35 | } 36 | 37 | fmt.Printf("url: %v\n", u) 38 | fmt.Printf("signature: %v\n", base64.URLEncoding.EncodeToString(sig)) 39 | } 40 | 41 | func sign(key string, s string, urlOnly bool) ([]byte, error) { 42 | if s == "" { 43 | return nil, errors.New("imageproxy-sign url [key]") 44 | } 45 | 46 | u := parseURL(s) 47 | if u == nil { 48 | return nil, fmt.Errorf("unable to parse URL: %v", s) 49 | } 50 | if urlOnly { 51 | u.Fragment = "" 52 | } 53 | 54 | k, err := parseKey(key) 55 | if err != nil { 56 | return nil, fmt.Errorf("error parsing key: %v", err) 57 | } 58 | 59 | mac := hmac.New(sha256.New, k) 60 | if _, err := mac.Write([]byte(u.String())); err != nil { 61 | return nil, err 62 | } 63 | return mac.Sum(nil), nil 64 | } 65 | 66 | func parseKey(s string) ([]byte, error) { 67 | if strings.HasPrefix(s, "@") { 68 | return ioutil.ReadFile(s[1:]) 69 | } 70 | return []byte(s), nil 71 | } 72 | 73 | // parseURL parses s as either an imageproxy request URL or a remote URL with 74 | // options in the URL fragment. Any existing signature values are stripped, 75 | // and the final remote URL returned with remaining options in the fragment. 76 | func parseURL(s string) *url.URL { 77 | u, err := url.Parse(s) 78 | if s == "" || err != nil { 79 | return nil 80 | } 81 | 82 | // first try to parse this as an imageproxy URL, containing 83 | // transformation options and the remote URL embedded 84 | if r, err := imageproxy.NewRequest(&http.Request{URL: u}, nil); err == nil { 85 | r.Options.Signature = "" 86 | r.URL.Fragment = r.Options.String() 87 | return r.URL 88 | } 89 | 90 | // second, we assume that this is the remote URL itself. If a fragment 91 | // is present, treat it as an option string. 92 | opt := imageproxy.ParseOptions(u.Fragment) 93 | opt.Signature = "" 94 | u.Fragment = opt.String() 95 | return u 96 | } 97 | -------------------------------------------------------------------------------- /internal/transport/transport.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "net/http" 8 | 9 | "github.com/fcjr/aia-transport-go" 10 | ) 11 | 12 | type DialContextFn func(ctx context.Context, network string, addr string) (net.Conn, error) 13 | type DialFn func(network string, addr string) (net.Conn, error) 14 | 15 | var zeroDialer net.Dialer 16 | 17 | // NewTransport returns a http.Transport that supports a deny list of hosts 18 | // that won't be dialed. 19 | func NewTransport(denyHosts []string) (*http.Transport, error) { 20 | t, err := aia.NewTransport() 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | if t.DialContext != nil { 26 | t.DialContext = wrapDialContextWithDenyHosts(t.DialContext, denyHosts) 27 | } else if t.Dial != nil { 28 | t.Dial = wrapDialWithDenyHosts(t.Dial, denyHosts) 29 | } else { 30 | t.DialContext = wrapDialContextWithDenyHosts(zeroDialer.DialContext, denyHosts) 31 | } 32 | 33 | // When there's no custom TLS dialer, dial and any custom non-TLS dialer is used 34 | // so we'd be covered by the above wrapping 35 | if t.DialTLS != nil { 36 | t.DialTLS = wrapDialWithDenyHosts(t.DialTLS, denyHosts) 37 | } 38 | 39 | return t, nil 40 | } 41 | 42 | func wrapDialContextWithDenyHosts(fn DialContextFn, denyHosts []string) (wrappedFn DialContextFn) { 43 | wrappedFn = func(ctx context.Context, network string, addr string) (net.Conn, error) { 44 | conn, err := fn(ctx, network, addr) 45 | if err != nil { 46 | return conn, err 47 | } 48 | 49 | if denied := checkAddr(denyHosts, conn.RemoteAddr().String()); denied == nil { 50 | return conn, err 51 | } else { 52 | return nil, denied 53 | } 54 | } 55 | 56 | return 57 | } 58 | 59 | func wrapDialWithDenyHosts(fn DialFn, denyHosts []string) (wrappedFn DialFn) { 60 | wrappedFn = func(network string, addr string) (net.Conn, error) { 61 | conn, err := fn(network, addr) 62 | if err != nil { 63 | return conn, err 64 | } 65 | 66 | if denied := checkAddr(denyHosts, conn.RemoteAddr().String()); denied == nil { 67 | return conn, err 68 | } else { 69 | return nil, denied 70 | } 71 | } 72 | 73 | return 74 | } 75 | 76 | // checkAddr returns resolved addr an error if addr matches any of the hosts or CIDR given 77 | func checkAddr(hosts []string, addr string) error { 78 | host, _, err := net.SplitHostPort(addr) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | if ip := net.ParseIP(host); ip != nil { 84 | errDeniedHost := errors.New("address matches a denied host") 85 | 86 | for _, host := range hosts { 87 | if _, ipnet, err := net.ParseCIDR(host); err == nil { 88 | if ipnet.Contains(ip) { 89 | return errDeniedHost 90 | } 91 | } else if ip.String() == host { 92 | return errDeniedHost 93 | } 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/s3cache/s3cache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Package s3cache provides an httpcache.Cache implementation that stores 5 | // cached values on Amazon S3. 6 | package s3cache 7 | 8 | import ( 9 | "bytes" 10 | "crypto/md5" 11 | "encoding/hex" 12 | "io" 13 | "io/ioutil" 14 | "log" 15 | "net/url" 16 | "path" 17 | "strings" 18 | 19 | "github.com/aws/aws-sdk-go/aws" 20 | "github.com/aws/aws-sdk-go/aws/awserr" 21 | "github.com/aws/aws-sdk-go/aws/session" 22 | "github.com/aws/aws-sdk-go/service/s3" 23 | ) 24 | 25 | type cache struct { 26 | *s3.S3 27 | bucket, prefix string 28 | } 29 | 30 | func (c *cache) Get(key string) ([]byte, bool) { 31 | key = path.Join(c.prefix, keyToFilename(key)) 32 | input := &s3.GetObjectInput{ 33 | Bucket: &c.bucket, 34 | Key: &key, 35 | } 36 | 37 | resp, err := c.GetObject(input) 38 | if err != nil { 39 | if aerr, ok := err.(awserr.Error); ok && aerr.Code() != "NoSuchKey" { 40 | log.Printf("error fetching from s3: %v", aerr) 41 | } 42 | return nil, false 43 | } 44 | 45 | value, err := ioutil.ReadAll(resp.Body) 46 | if err != nil { 47 | log.Printf("error reading s3 response body: %v", err) 48 | return nil, false 49 | } 50 | 51 | return value, true 52 | } 53 | func (c *cache) Set(key string, value []byte) { 54 | key = path.Join(c.prefix, keyToFilename(key)) 55 | input := &s3.PutObjectInput{ 56 | Body: aws.ReadSeekCloser(bytes.NewReader(value)), 57 | Bucket: &c.bucket, 58 | Key: &key, 59 | } 60 | 61 | _, err := c.PutObject(input) 62 | if err != nil { 63 | log.Printf("error writing to s3: %v", err) 64 | } 65 | } 66 | func (c *cache) Delete(key string) { 67 | key = path.Join(c.prefix, keyToFilename(key)) 68 | input := &s3.DeleteObjectInput{ 69 | Bucket: &c.bucket, 70 | Key: &key, 71 | } 72 | 73 | _, err := c.DeleteObject(input) 74 | if err != nil { 75 | log.Printf("error deleting from s3: %v", err) 76 | } 77 | } 78 | 79 | func keyToFilename(key string) string { 80 | h := md5.New() 81 | _, _ = io.WriteString(h, key) 82 | return hex.EncodeToString(h.Sum(nil)) 83 | } 84 | 85 | // New constructs a cache configured using the provided URL string. URL should 86 | // be of the form: "s3://region/bucket/optional-path-prefix". Credentials 87 | // should be specified using one of the mechanisms supported by aws-sdk-go (see 88 | // https://docs.aws.amazon.com/sdk-for-go/api/aws/session/). 89 | func New(s string) (*cache, error) { 90 | u, err := url.Parse(s) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | region := u.Host 96 | path := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2) 97 | bucket := path[0] 98 | var prefix string 99 | if len(path) > 1 { 100 | prefix = path[1] 101 | } 102 | 103 | config := aws.NewConfig().WithRegion(region) 104 | 105 | // allow overriding some additional config options, mostly useful when 106 | // working with s3-compatible services other than AWS. 107 | if v := u.Query().Get("endpoint"); v != "" { 108 | config = config.WithEndpoint(v) 109 | } 110 | if v := u.Query().Get("disableSSL"); v == "1" { 111 | config = config.WithDisableSSL(true) 112 | } 113 | if v := u.Query().Get("s3ForcePathStyle"); v == "1" { 114 | config = config.WithS3ForcePathStyle(true) 115 | } 116 | 117 | sess, err := session.NewSession(config) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | return &cache{ 123 | S3: s3.New(sess), 124 | bucket: bucket, 125 | prefix: prefix, 126 | }, nil 127 | } 128 | -------------------------------------------------------------------------------- /third_party/http/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package http provides helpers for HTTP servers. 6 | package http 7 | 8 | import ( 9 | "bytes" 10 | "errors" 11 | "io" 12 | "net/http" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | // TimeoutHandler returns a Handler that runs h with the given time limit. 18 | // 19 | // The new Handler calls h.ServeHTTP to handle each request, but if a 20 | // call runs for longer than its time limit, the handler responds with 21 | // a 504 Gateway Timeout error and the given message in its body. 22 | // (If msg is empty, a suitable default message will be sent.) 23 | // After such a timeout, writes by h to its ResponseWriter will return 24 | // ErrHandlerTimeout. 25 | // 26 | // TimeoutHandler buffers all Handler writes to memory and does not 27 | // support the Hijacker or Flusher interfaces. 28 | func TimeoutHandler(h http.Handler, dt time.Duration, msg string) http.Handler { 29 | return &timeoutHandler{ 30 | handler: h, 31 | body: msg, 32 | dt: dt, 33 | } 34 | } 35 | 36 | // ErrHandlerTimeout is returned on ResponseWriter Write calls 37 | // in handlers which have timed out. 38 | var ErrHandlerTimeout = errors.New("http: Handler timeout") 39 | 40 | type timeoutHandler struct { 41 | handler http.Handler 42 | body string 43 | dt time.Duration 44 | 45 | // When set, no timer will be created and this channel will 46 | // be used instead. 47 | testTimeout <-chan time.Time 48 | } 49 | 50 | func (h *timeoutHandler) errorBody() string { 51 | if h.body != "" { 52 | return h.body 53 | } 54 | return "Timeout

Timeout

" 55 | } 56 | 57 | func (h *timeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 58 | var t *time.Timer 59 | timeout := h.testTimeout 60 | if timeout == nil { 61 | t = time.NewTimer(h.dt) 62 | timeout = t.C 63 | } 64 | done := make(chan struct{}) 65 | tw := &timeoutWriter{ 66 | w: w, 67 | h: make(http.Header), 68 | } 69 | go func() { 70 | h.handler.ServeHTTP(tw, r) 71 | close(done) 72 | }() 73 | select { 74 | case <-done: 75 | tw.mu.Lock() 76 | defer tw.mu.Unlock() 77 | dst := w.Header() 78 | for k, vv := range tw.h { 79 | dst[k] = vv 80 | } 81 | if !tw.wroteHeader { 82 | tw.code = http.StatusOK 83 | } 84 | w.WriteHeader(tw.code) 85 | w.Write(tw.wbuf.Bytes()) 86 | if t != nil { 87 | t.Stop() 88 | } 89 | case <-timeout: 90 | tw.mu.Lock() 91 | defer tw.mu.Unlock() 92 | w.WriteHeader(http.StatusGatewayTimeout) 93 | io.WriteString(w, h.errorBody()) 94 | tw.timedOut = true 95 | return 96 | } 97 | } 98 | 99 | type timeoutWriter struct { 100 | w http.ResponseWriter 101 | h http.Header 102 | wbuf bytes.Buffer 103 | 104 | mu sync.Mutex 105 | timedOut bool 106 | wroteHeader bool 107 | code int 108 | } 109 | 110 | func (tw *timeoutWriter) Header() http.Header { return tw.h } 111 | 112 | func (tw *timeoutWriter) Write(p []byte) (int, error) { 113 | tw.mu.Lock() 114 | defer tw.mu.Unlock() 115 | if tw.timedOut { 116 | return 0, ErrHandlerTimeout 117 | } 118 | if !tw.wroteHeader { 119 | tw.writeHeader(http.StatusOK) 120 | } 121 | return tw.wbuf.Write(p) 122 | } 123 | 124 | func (tw *timeoutWriter) WriteHeader(code int) { 125 | tw.mu.Lock() 126 | defer tw.mu.Unlock() 127 | if tw.timedOut || tw.wroteHeader { 128 | return 129 | } 130 | tw.writeHeader(code) 131 | } 132 | 133 | func (tw *timeoutWriter) writeHeader(code int) { 134 | tw.wroteHeader = true 135 | tw.code = code 136 | } 137 | -------------------------------------------------------------------------------- /cmd/imageproxy-sign/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "io/ioutil" 8 | "net/url" 9 | "os" 10 | "reflect" 11 | "testing" 12 | ) 13 | 14 | var key = "secret" 15 | 16 | func TestMainFunc(t *testing.T) { 17 | os.Args = []string{"imageproxy-sign", "-key", key, "http://example.com/#0x0"} 18 | r, w, err := os.Pipe() 19 | if err != nil { 20 | t.Errorf("error creating pipe: %v", err) 21 | } 22 | defer r.Close() 23 | os.Stdout = w 24 | 25 | main() 26 | w.Close() 27 | 28 | output, err := ioutil.ReadAll(r) 29 | got := string(output) 30 | if err != nil { 31 | t.Errorf("error reading from pipe: %v", err) 32 | } 33 | 34 | want := "url: http://example.com/#0x0\nsignature: pwlnJ3bVazxg2nQxClimqT0VnNxUm5W0cdyg1HpKUPY=\n" 35 | if got != want { 36 | t.Errorf("main output %q, want %q", got, want) 37 | } 38 | } 39 | 40 | func TestSign(t *testing.T) { 41 | s := "http://example.com/image.jpg#0x0" 42 | 43 | got, err := sign(key, s, false) 44 | if err != nil { 45 | t.Errorf("sign(%q, %q, false) returned error: %v", key, s, err) 46 | } 47 | want := []byte{0xc3, 0x4c, 0x45, 0xb5, 0x75, 0x84, 0x76, 0xdf, 0xd9, 0x6b, 0x12, 0xa4, 0x84, 0x8f, 0x37, 0xc6, 0x2d, 0x8b, 0x8d, 0x77, 0xda, 0x6, 0xf8, 0xb5, 0x10, 0xc9, 0x96, 0x3c, 0x6e, 0x13, 0xda, 0xf0} 48 | if !reflect.DeepEqual(got, want) { 49 | t.Errorf("sign(%q, %q, true) returned %v, want %v", key, s, got, want) 50 | } 51 | } 52 | 53 | func TestSign_URLOnly(t *testing.T) { 54 | s := "http://example.com/image.jpg#0x0" 55 | 56 | got, err := sign(key, s, true) 57 | if err != nil { 58 | t.Errorf("sign(%q, %q, true) returned error: %v", key, s, err) 59 | } 60 | want := []byte{0x93, 0xea, 0x5d, 0x23, 0x68, 0xa0, 0xfc, 0x50, 0x8e, 0x91, 0x7, 0xbf, 0x3e, 0xb3, 0x1f, 0x49, 0xf7, 0x1d, 0x81, 0xf1, 0x74, 0xfe, 0x25, 0x36, 0xfc, 0x74, 0xf8, 0x81, 0x15, 0xf5, 0x58, 0x40} 61 | if !reflect.DeepEqual(got, want) { 62 | t.Errorf("sign(%q, %q, true) returned %v, want %v", key, s, got, want) 63 | } 64 | } 65 | 66 | func TestSign_Errors(t *testing.T) { 67 | var err error 68 | 69 | tests := []struct { 70 | key, url string 71 | }{ 72 | {"", ""}, 73 | {"", "%"}, 74 | {"@/does/not/exist", "s"}, 75 | } 76 | 77 | for _, tt := range tests { 78 | _, err = sign(tt.key, tt.url, false) 79 | if err == nil { 80 | t.Errorf("sign(%q, %q, false) did not return expected error", tt.key, tt.url) 81 | } 82 | } 83 | } 84 | 85 | func TestParseKey(t *testing.T) { 86 | k, err := parseKey(key) 87 | got := string(k) 88 | if err != nil { 89 | t.Errorf("parseKey(%q) returned error: %v", key, err) 90 | } 91 | if want := key; got != want { 92 | t.Errorf("parseKey(%q) returned %v, want %v", key, got, want) 93 | } 94 | } 95 | 96 | func TestParseKey_FilePath(t *testing.T) { 97 | f, err := ioutil.TempFile("", "key") 98 | if err != nil { 99 | t.Errorf("error creating temp file: %v", err) 100 | } 101 | defer func() { 102 | f.Close() 103 | os.Remove(f.Name()) 104 | }() 105 | 106 | if _, err := f.WriteString(key); err != nil { 107 | t.Errorf("error writing to temp file: %v", err) 108 | } 109 | path := "@" + f.Name() 110 | k, err := parseKey(path) 111 | got := string(k) 112 | if err != nil { 113 | t.Errorf("parseKey(%q) returned error: %v", path, err) 114 | } 115 | if want := key; got != want { 116 | t.Errorf("parseKey(%q) returned %v, want %v", path, got, want) 117 | } 118 | } 119 | 120 | func TestParseURL(t *testing.T) { 121 | tests := []struct { 122 | input, output string 123 | }{ 124 | {"/", "/#0x0"}, 125 | 126 | // imageproxy URLs 127 | {"http://localhost:8080//http://example.com/", "http://example.com/#0x0"}, 128 | {"http://localhost:8080/10,r90,jpeg/http://example.com/", "http://example.com/#10x10,jpeg,r90"}, 129 | 130 | // remote URLs, with and without options 131 | {"http://example.com/", "http://example.com/#0x0"}, 132 | {"http://example.com/#r90,jpeg", "http://example.com/#0x0,jpeg,r90"}, 133 | 134 | // ensure signature values are stripped 135 | {"http://localhost:8080/sc0ffee/http://example.com/", "http://example.com/#0x0"}, 136 | {"http://example.com/#sc0ffee", "http://example.com/#0x0"}, 137 | } 138 | 139 | for _, tt := range tests { 140 | want, _ := url.Parse(tt.output) 141 | got := parseURL(tt.input) 142 | if got.String() != want.String() { 143 | t.Errorf("parseURL(%q) returned %q, want %q", tt.input, got, want) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /data_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | import ( 7 | "net/http" 8 | "net/url" 9 | "testing" 10 | ) 11 | 12 | var emptyOptions = Options{} 13 | 14 | func TestOptions_String(t *testing.T) { 15 | tests := []struct { 16 | Options Options 17 | String string 18 | }{ 19 | { 20 | emptyOptions, 21 | "0x0", 22 | }, 23 | { 24 | Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 80}, 25 | "1x2,fh,fit,fv,q80,r90", 26 | }, 27 | { 28 | Options{Width: 0.15, Height: 1.3, Rotate: 45, Quality: 95, Signature: "c0ffee", Format: "png"}, 29 | "0.15x1.3,png,q95,r45,sc0ffee", 30 | }, 31 | { 32 | Options{Width: 0.15, Height: 1.3, CropX: 100, CropY: 200}, 33 | "0.15x1.3,cx100,cy200", 34 | }, 35 | { 36 | Options{ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true}, 37 | "0x0,ch400,cw300,cx100,cy200,sc,scaleUp", 38 | }, 39 | } 40 | 41 | for i, tt := range tests { 42 | if got, want := tt.Options.String(), tt.String; got != want { 43 | t.Errorf("%d. Options.String returned %v, want %v", i, got, want) 44 | } 45 | } 46 | } 47 | 48 | func TestParseOptions(t *testing.T) { 49 | tests := []struct { 50 | Input string 51 | Options Options 52 | }{ 53 | {"", emptyOptions}, 54 | {"x", emptyOptions}, 55 | {"r", emptyOptions}, 56 | {"0", emptyOptions}, 57 | {",,,,", emptyOptions}, 58 | 59 | // size variations 60 | {"1x", Options{Width: 1}}, 61 | {"x1", Options{Height: 1}}, 62 | {"1x2", Options{Width: 1, Height: 2}}, 63 | {"-1x-2", Options{Width: -1, Height: -2}}, 64 | {"0.1x0.2", Options{Width: 0.1, Height: 0.2}}, 65 | {"1", Options{Width: 1, Height: 1}}, 66 | {"0.1", Options{Width: 0.1, Height: 0.1}}, 67 | 68 | // additional flags 69 | {"fit", Options{Fit: true}}, 70 | {"r90", Options{Rotate: 90}}, 71 | {"fv", Options{FlipVertical: true}}, 72 | {"fh", Options{FlipHorizontal: true}}, 73 | {"jpeg", Options{Format: "jpeg"}}, 74 | 75 | // duplicate flags (last one wins) 76 | {"1x2,3x4", Options{Width: 3, Height: 4}}, 77 | {"1x2,3", Options{Width: 3, Height: 3}}, 78 | {"1x2,0x3", Options{Width: 0, Height: 3}}, 79 | {"1x,x2", Options{Width: 1, Height: 2}}, 80 | {"r90,r270", Options{Rotate: 270}}, 81 | {"jpeg,png", Options{Format: "png"}}, 82 | 83 | // mix of valid and invalid flags 84 | {"FOO,1,BAR,r90,BAZ", Options{Width: 1, Height: 1, Rotate: 90}}, 85 | 86 | // flags, in different orders 87 | {"q70,1x2,fit,r90,fv,fh,sc0ffee,png", Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 70, Signature: "c0ffee", Format: "png"}}, 88 | {"r90,fh,sc0ffee,png,q90,1x2,fv,fit", Options{Width: 1, Height: 2, Fit: true, Rotate: 90, FlipVertical: true, FlipHorizontal: true, Quality: 90, Signature: "c0ffee", Format: "png"}}, 89 | {"cx100,cw300,1x2,cy200,ch400,sc,scaleUp", Options{Width: 1, Height: 2, ScaleUp: true, CropX: 100, CropY: 200, CropWidth: 300, CropHeight: 400, SmartCrop: true}}, 90 | } 91 | 92 | for _, tt := range tests { 93 | if got, want := ParseOptions(tt.Input), tt.Options; got != want { 94 | t.Errorf("ParseOptions(%q) returned %#v, want %#v", tt.Input, got, want) 95 | } 96 | } 97 | } 98 | 99 | // Test that request URLs are properly parsed into Options and RemoteURL. This 100 | // test verifies that invalid remote URLs throw errors, and that valid 101 | // combinations of Options and URL are accept. This does not exhaustively test 102 | // the various Options that can be specified; see TestParseOptions for that. 103 | func TestNewRequest(t *testing.T) { 104 | tests := []struct { 105 | URL string // input URL to parse as an imageproxy request 106 | RemoteURL string // expected URL of remote image parsed from input 107 | Options Options // expected options parsed from input 108 | ExpectError bool // whether an error is expected from NewRequest 109 | }{ 110 | // invalid URLs 111 | {"http://localhost/", "", emptyOptions, true}, 112 | {"http://localhost/1/", "", emptyOptions, true}, 113 | {"http://localhost//example.com/foo", "", emptyOptions, true}, 114 | {"http://localhost//ftp://example.com/foo", "", emptyOptions, true}, 115 | 116 | // invalid options. These won't return errors, but will not fully parse the options 117 | { 118 | "http://localhost/s/http://example.com/", 119 | "http://example.com/", emptyOptions, false, 120 | }, 121 | { 122 | "http://localhost/1xs/http://example.com/", 123 | "http://example.com/", Options{Width: 1}, false, 124 | }, 125 | 126 | // valid URLs 127 | { 128 | "http://localhost/http://example.com/foo", 129 | "http://example.com/foo", emptyOptions, false, 130 | }, 131 | { 132 | "http://localhost//http://example.com/foo", 133 | "http://example.com/foo", emptyOptions, false, 134 | }, 135 | { 136 | "http://localhost//https://example.com/foo", 137 | "https://example.com/foo", emptyOptions, false, 138 | }, 139 | { 140 | "http://localhost/1x2/http://example.com/foo", 141 | "http://example.com/foo", Options{Width: 1, Height: 2}, false, 142 | }, 143 | { 144 | "http://localhost//http://example.com/foo?bar", 145 | "http://example.com/foo?bar", emptyOptions, false, 146 | }, 147 | { 148 | "http://localhost/http:/example.com/foo", 149 | "http://example.com/foo", emptyOptions, false, 150 | }, 151 | { 152 | "http://localhost/http:///example.com/foo", 153 | "http://example.com/foo", emptyOptions, false, 154 | }, 155 | { // escaped path 156 | "http://localhost/http://example.com/%2C", 157 | "http://example.com/%2C", emptyOptions, false, 158 | }, 159 | } 160 | 161 | for _, tt := range tests { 162 | req, err := http.NewRequest("GET", tt.URL, nil) 163 | if err != nil { 164 | t.Errorf("http.NewRequest(%q) returned error: %v", tt.URL, err) 165 | continue 166 | } 167 | 168 | r, err := NewRequest(req, nil) 169 | if tt.ExpectError { 170 | if err == nil { 171 | t.Errorf("NewRequest(%v) did not return expected error", req) 172 | } 173 | continue 174 | } else if err != nil { 175 | t.Errorf("NewRequest(%v) return unexpected error: %v", req, err) 176 | continue 177 | } 178 | 179 | if got, want := r.URL.String(), tt.RemoteURL; got != want { 180 | t.Errorf("NewRequest(%q) request URL = %v, want %v", tt.URL, got, want) 181 | } 182 | if got, want := r.Options, tt.Options; got != want { 183 | t.Errorf("NewRequest(%q) request options = %v, want %v", tt.URL, got, want) 184 | } 185 | } 186 | } 187 | 188 | func TestNewRequest_BaseURL(t *testing.T) { 189 | req, _ := http.NewRequest("GET", "/x/path", nil) 190 | base, _ := url.Parse("https://example.com/") 191 | 192 | r, err := NewRequest(req, base) 193 | if err != nil { 194 | t.Errorf("NewRequest(%v, %v) returned unexpected error: %v", req, base, err) 195 | } 196 | 197 | want := "https://example.com/path#0x0" 198 | if got := r.String(); got != want { 199 | t.Errorf("NewRequest(%v, %v) returned %q, want %q", req, base, got, want) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /cmd/imageproxy/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // imageproxy starts an HTTP server that proxies requests for remote images. 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "fmt" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "strconv" 16 | "strings" 17 | "time" 18 | 19 | "github.com/PaulARoy/azurestoragecache" 20 | "github.com/die-net/lrucache" 21 | "github.com/die-net/lrucache/twotier" 22 | "github.com/gomodule/redigo/redis" 23 | "github.com/gorilla/mux" 24 | "github.com/gregjones/httpcache/diskcache" 25 | rediscache "github.com/gregjones/httpcache/redis" 26 | "github.com/jamiealquiza/envy" 27 | "github.com/peterbourgon/diskv" 28 | "willnorris.com/go/imageproxy" 29 | "willnorris.com/go/imageproxy/internal/gcscache" 30 | "willnorris.com/go/imageproxy/internal/s3cache" 31 | denyHostsTransport "willnorris.com/go/imageproxy/internal/transport" 32 | ) 33 | 34 | const defaultMemorySize = 100 35 | 36 | var addr = flag.String("addr", "localhost:8080", "TCP address to listen on") 37 | var allowHosts = flag.String("allowHosts", "", "comma separated list of allowed remote hosts") 38 | var denyHosts = flag.String("denyHosts", "", "comma separated list of denied remote hosts") 39 | var referrers = flag.String("referrers", "", "comma separated list of allowed referring hosts") 40 | var includeReferer = flag.Bool("includeReferer", false, "include referer header in remote requests") 41 | var followRedirects = flag.Bool("followRedirects", true, "follow redirects") 42 | var baseURL = flag.String("baseURL", "", "default base URL for relative remote URLs") 43 | var cache tieredCache 44 | var signatureKeys signatureKeyList 45 | var scaleUp = flag.Bool("scaleUp", false, "allow images to scale beyond their original dimensions") 46 | var timeout = flag.Duration("timeout", 0, "time limit for requests served by this proxy") 47 | var verbose = flag.Bool("verbose", false, "print verbose logging messages") 48 | var _ = flag.Bool("version", false, "Deprecated: this flag does nothing") 49 | var contentTypes = flag.String("contentTypes", "image/*", "comma separated list of allowed content types") 50 | var userAgent = flag.String("userAgent", "willnorris/imageproxy", "specify the user-agent used by imageproxy when fetching images from origin website") 51 | 52 | func init() { 53 | flag.Var(&cache, "cache", "location to cache images (see https://github.com/willnorris/imageproxy#cache)") 54 | flag.Var(&signatureKeys, "signatureKey", "HMAC key used in calculating request signatures") 55 | } 56 | 57 | func main() { 58 | envy.Parse("IMAGEPROXY") 59 | flag.Parse() 60 | 61 | parsedDenyHosts := []string{} 62 | if *denyHosts != "" { 63 | parsedDenyHosts = strings.Split(*denyHosts, ",") 64 | } 65 | 66 | transport, _ := denyHostsTransport.NewTransport(parsedDenyHosts) 67 | p := imageproxy.NewProxy(transport, cache.Cache) 68 | 69 | p.DenyHosts = parsedDenyHosts 70 | if *allowHosts != "" { 71 | p.AllowHosts = strings.Split(*allowHosts, ",") 72 | } 73 | if *referrers != "" { 74 | p.Referrers = strings.Split(*referrers, ",") 75 | } 76 | if *contentTypes != "" { 77 | p.ContentTypes = strings.Split(*contentTypes, ",") 78 | } 79 | p.SignatureKeys = signatureKeys 80 | if *baseURL != "" { 81 | var err error 82 | p.DefaultBaseURL, err = url.Parse(*baseURL) 83 | if err != nil { 84 | log.Fatalf("error parsing baseURL: %v", err) 85 | } 86 | } 87 | 88 | p.IncludeReferer = *includeReferer 89 | p.FollowRedirects = *followRedirects 90 | p.Timeout = *timeout 91 | p.ScaleUp = *scaleUp 92 | p.Verbose = *verbose 93 | p.UserAgent = *userAgent 94 | 95 | server := &http.Server{ 96 | Addr: *addr, 97 | Handler: p, 98 | } 99 | 100 | r := mux.NewRouter().SkipClean(true).UseEncodedPath() 101 | r.PathPrefix("/").Handler(p) 102 | fmt.Printf("imageproxy listening on %s\n", server.Addr) 103 | log.Fatal(http.ListenAndServe(*addr, r)) 104 | } 105 | 106 | type signatureKeyList [][]byte 107 | 108 | func (skl *signatureKeyList) String() string { 109 | return fmt.Sprint(*skl) 110 | } 111 | 112 | func (skl *signatureKeyList) Set(value string) error { 113 | for _, v := range strings.Fields(value) { 114 | key := []byte(v) 115 | if strings.HasPrefix(v, "@") { 116 | file := strings.TrimPrefix(v, "@") 117 | var err error 118 | key, err = ioutil.ReadFile(file) 119 | if err != nil { 120 | log.Fatalf("error reading signature file: %v", err) 121 | } 122 | } 123 | *skl = append(*skl, key) 124 | } 125 | return nil 126 | } 127 | 128 | // tieredCache allows specifying multiple caches via flags, which will create 129 | // tiered caches using the twotier package. 130 | type tieredCache struct { 131 | imageproxy.Cache 132 | } 133 | 134 | func (tc *tieredCache) String() string { 135 | return fmt.Sprint(*tc) 136 | } 137 | 138 | func (tc *tieredCache) Set(value string) error { 139 | for _, v := range strings.Fields(value) { 140 | c, err := parseCache(v) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | if tc.Cache == nil { 146 | tc.Cache = c 147 | } else { 148 | tc.Cache = twotier.New(tc.Cache, c) 149 | } 150 | } 151 | return nil 152 | } 153 | 154 | // parseCache parses c returns the specified Cache implementation. 155 | func parseCache(c string) (imageproxy.Cache, error) { 156 | if c == "" { 157 | return nil, nil 158 | } 159 | 160 | if c == "memory" { 161 | c = fmt.Sprintf("memory:%d", defaultMemorySize) 162 | } 163 | 164 | u, err := url.Parse(c) 165 | if err != nil { 166 | return nil, fmt.Errorf("error parsing cache flag: %v", err) 167 | } 168 | 169 | switch u.Scheme { 170 | case "azure": 171 | return azurestoragecache.New("", "", u.Host) 172 | case "gcs": 173 | return gcscache.New(u.Host, strings.TrimPrefix(u.Path, "/")) 174 | case "memory": 175 | return lruCache(u.Opaque) 176 | case "redis": 177 | conn, err := redis.DialURL(u.String(), redis.DialPassword(os.Getenv("REDIS_PASSWORD"))) 178 | if err != nil { 179 | return nil, err 180 | } 181 | return rediscache.NewWithClient(conn), nil 182 | case "s3": 183 | return s3cache.New(u.String()) 184 | case "file": 185 | return diskCache(u.Path), nil 186 | default: 187 | return diskCache(c), nil 188 | } 189 | } 190 | 191 | // lruCache creates an LRU Cache with the specified options of the form 192 | // "maxSize:maxAge". maxSize is specified in megabytes, maxAge is a duration. 193 | func lruCache(options string) (*lrucache.LruCache, error) { 194 | parts := strings.SplitN(options, ":", 2) 195 | size, err := strconv.ParseInt(parts[0], 10, 64) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | var age time.Duration 201 | if len(parts) > 1 { 202 | age, err = time.ParseDuration(parts[1]) 203 | if err != nil { 204 | return nil, err 205 | } 206 | } 207 | 208 | return lrucache.New(size*1e6, int64(age.Seconds())), nil 209 | } 210 | 211 | func diskCache(path string) *diskcache.Cache { 212 | d := diskv.New(diskv.Options{ 213 | BasePath: path, 214 | 215 | // For file "c0ffee", store file as "c0/ff/c0ffee" 216 | Transform: func(s string) []string { return []string{s[0:2], s[2:4]} }, 217 | }) 218 | return diskcache.NewWithDiskv(d) 219 | } 220 | -------------------------------------------------------------------------------- /docs/url-signing.md: -------------------------------------------------------------------------------- 1 | # How to generate signed requests 2 | 3 | Signing requests allows an imageproxy instance to proxy images from arbitrary 4 | remote hosts, but without opening the service up for potential abuse. When 5 | appropriately configured, the imageproxy instance will only serve requests that 6 | are for allowed hosts, or which have a valid signature. 7 | 8 | Signatures can be calculated in two ways: 9 | 10 | 1. they can be calculated solely on the remote image URL, in which case any 11 | transformations of the image can be requested without changes to the 12 | signature value. This used to be the only way to sign requests, but is no 13 | longer recommended since it still leaves the imageproxy instance open to 14 | potential abuse. 15 | 16 | 2. they can be calculated based on the combination of the remote image URL and 17 | the requested transformation options. 18 | 19 | In both cases, the signature is calculated using HMAC-SHA256 and a secret key 20 | which is provided to imageproxy on startup. The message to be signed is the 21 | remote URL, with the transformation options optionally set as the URL fragment, 22 | [as documented below](#Signing-options). The signature is url-safe base64 23 | encoded, and [provided as an option][s-option] in the imageproxy request. 24 | 25 | imageproxy will accept signatures for URLs with or without options 26 | transparently. It's up to the publisher of the signed URLs to decide which 27 | method they use to generate the URL. 28 | 29 | [s-option]: https://godoc.org/willnorris.com/go/imageproxy#hdr-Signature 30 | 31 | ## Signing options 32 | 33 | Transformation options for a proxied URL are [specified as a comma separated 34 | string][ParseOptions] of individual options, which can be supplied in any 35 | order. When calculating a signature, options should be put in their canonical 36 | form, sorted in lexigraphical order (omitting the signature option itself), and 37 | appended to the remote URL as the URL fragment. 38 | 39 | Currently, only [size option][] has a canonical form, which is 40 | `{width}x{height}` with the number `0` used when no value is specified. For 41 | example, a request that does not request any size option would still have a 42 | canonical size value of `0x0`, indicating that no size transformation is being 43 | performed. If only a height of 500px is requested, the canonical form would be 44 | `0x500`. 45 | 46 | For example, requesting the remote URL of `http://example.com/image.jpg`, 47 | resized to 100 pixels square, rotated 90 degrees, and converted to 75% quality 48 | might produce an imageproxy URL similar to: 49 | 50 | http://localhost:8080/100,r90,q75/http://example.com/image.jpg 51 | 52 | When calculating a signature for this request including transformation options, 53 | the signed value would be: 54 | 55 | http://example.com/image.jpg#100x100,q75,r90 56 | 57 | The `100` size option was put in its canonical form of `100x100`, and the 58 | options are sorted, moving `q75` before `r90`. 59 | 60 | [ParseOptions]: https://godoc.org/willnorris.com/go/imageproxy#ParseOptions 61 | [size option]: https://godoc.org/willnorris.com/go/imageproxy#hdr-Size_and_Cropping 62 | 63 | 64 | ## Signed options example 65 | 66 | Here is an example with signed options through each step. 67 | 68 | Using the github codercat, our image url is `https://octodex.github.com/images/codercat.jpg` and our options are `400x400` and `q40`. 69 | 70 | The signature key is `secretkey` 71 | 72 | The value that goes into the Digest is `https://octodex.github.com/images/codercat.jpg#400x400,q40` 73 | 74 | and our resulting signed key is `0sR2kjyfiF1RQRj4Jm2fFa3_6SDFqdAaDEmy1oD2U-4=` 75 | 76 | The final url would be 77 | `http://localhost:8080/400x400,q40,s0sR2kjyfiF1RQRj4Jm2fFa3_6SDFqdAaDEmy1oD2U-4=/https://octodex.github.com/images/codercat.jpg` 78 | 79 | 80 | 81 | ## Language Examples 82 | 83 | Here are examples of calculating signatures in a variety of languages. These 84 | demonstrate the HMAC-SHA256 bits, but not the option canonicalization. In each 85 | example, the remote URL `https://octodex.github.com/images/codercat.jpg` is 86 | signed using a signature key of `secretkey`. 87 | 88 | See also the [imageproxy-sign tool](/cmd/imageproxy-sign). 89 | 90 | ### Go 91 | 92 | main.go: 93 | ```go 94 | package main 95 | 96 | import ( 97 | "os" 98 | "fmt" 99 | "crypto/hmac" 100 | "crypto/sha256" 101 | "encoding/base64" 102 | ) 103 | 104 | func main() { 105 | key, url := os.Args[1], os.Args[2] 106 | mac := hmac.New(sha256.New, []byte(key)) 107 | mac.Write([]byte(url)) 108 | result := mac.Sum(nil) 109 | fmt.Println(base64.URLEncoding.EncodeToString(result)) 110 | } 111 | ``` 112 | 113 | ```shell 114 | $ go run sign.go "secretkey" "https://octodex.github.com/images/codercat.jpg" 115 | cw34eyalj8YvpLpETxSIxv2k8QkLel2UAR5Cku2FzGM= 116 | ``` 117 | 118 | ### OpenSSL 119 | 120 | ```shell 121 | $ echo -n "https://octodex.github.com/images/codercat.jpg" | openssl dgst -sha256 -hmac "secretkey" -binary|base64| tr '/+' '_-' 122 | cw34eyalj8YvpLpETxSIxv2k8QkLel2UAR5Cku2FzGM= 123 | ``` 124 | 125 | ### Java 126 | 127 | ```java 128 | import java.util.Base64; 129 | import javax.crypto.Mac; 130 | import javax.crypto.spec.SecretKeySpec; 131 | 132 | class SignUrl { 133 | 134 | public static String sign(String key, String url) throws Exception { 135 | Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); 136 | SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(), "HmacSHA256"); 137 | sha256_HMAC.init(secret_key); 138 | 139 | return Base64.getUrlEncoder().encodeToString(sha256_HMAC.doFinal(url.getBytes())); 140 | } 141 | 142 | public static void main(String [] args) throws Exception { 143 | System.out.println(sign(args[0], args[1])); 144 | } 145 | 146 | } 147 | ``` 148 | 149 | ```shell 150 | $ javac SignUrl.java && java SignUrl "secretkey" "https://octodex.github.com/images/codercat.jpg" 151 | cw34eyalj8YvpLpETxSIxv2k8QkLel2UAR5Cku2FzGM= 152 | ``` 153 | 154 | ### Ruby 155 | 156 | ```ruby 157 | require 'openssl' 158 | require 'base64' 159 | 160 | key = ARGV[0] 161 | url = ARGV[1] 162 | puts Base64.urlsafe_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, url)).strip() 163 | ``` 164 | 165 | ```shell 166 | % ruby sign.rb "secretkey" "https://octodex.github.com/images/codercat.jpg" 167 | cw34eyalj8YvpLpETxSIxv2k8QkLel2UAR5Cku2FzGM= 168 | ``` 169 | 170 | ### Python 171 | 172 | ```python 173 | import base64 174 | import hashlib 175 | import hmac 176 | import sys 177 | 178 | key = sys.argv[1] 179 | url = sys.argv[2] 180 | print base64.urlsafe_b64encode(hmac.new(key, msg=url, digestmod=hashlib.sha256).digest()) 181 | ``` 182 | 183 | ````shell 184 | $ python sign.py "secretkey" "https://octodex.github.com/images/codercat.jpg" 185 | cw34eyalj8YvpLpETxSIxv2k8QkLel2UAR5Cku2FzGM= 186 | ```` 187 | 188 | ### JavaScript 189 | 190 | ```javascript 191 | const crypto = require('crypto'); 192 | const URLSafeBase64 = require('urlsafe-base64'); 193 | 194 | let key = process.argv[2]; 195 | let url = process.argv[3]; 196 | console.log(URLSafeBase64.encode(crypto.createHmac('sha256', key).update(url).digest())); 197 | ``` 198 | 199 | ````shell 200 | $ node sign.js "secretkey" "https://octodex.github.com/images/codercat.jpg" 201 | cw34eyalj8YvpLpETxSIxv2k8QkLel2UAR5Cku2FzGM= 202 | ```` 203 | 204 | ### PHP 205 | 206 | ````php 207 | 100000000 { 56 | return nil, errors.New("image too large") 57 | } 58 | 59 | // decode image 60 | m, format, err := image.Decode(bytes.NewReader(img)) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | // apply EXIF orientation for jpeg and tiff source images. Read at most 66 | // up to maxExifSize looking for EXIF tags. 67 | if format == "jpeg" || format == "tiff" { 68 | r := io.LimitReader(bytes.NewReader(img), maxExifSize) 69 | if exifOpt := exifOrientation(r); exifOpt.transform() { 70 | m = transformImage(m, exifOpt) 71 | } 72 | } 73 | 74 | // encode webp and tiff as jpeg by default 75 | if format == "tiff" || format == "webp" { 76 | format = "jpeg" 77 | } 78 | 79 | if opt.Format != "" { 80 | format = opt.Format 81 | } 82 | 83 | // transform and encode image 84 | buf := new(bytes.Buffer) 85 | switch format { 86 | case "bmp": 87 | m = transformImage(m, opt) 88 | err = bmp.Encode(buf, m) 89 | if err != nil { 90 | return nil, err 91 | } 92 | case "gif": 93 | fn := func(img image.Image) image.Image { 94 | return transformImage(img, opt) 95 | } 96 | err = gifresize.Process(buf, bytes.NewReader(img), fn) 97 | if err != nil { 98 | return nil, err 99 | } 100 | case "jpeg": 101 | quality := opt.Quality 102 | if quality == 0 { 103 | quality = defaultQuality 104 | } 105 | 106 | m = transformImage(m, opt) 107 | err = jpeg.Encode(buf, m, &jpeg.Options{Quality: quality}) 108 | if err != nil { 109 | return nil, err 110 | } 111 | case "png": 112 | m = transformImage(m, opt) 113 | err = png.Encode(buf, m) 114 | if err != nil { 115 | return nil, err 116 | } 117 | case "tiff": 118 | m = transformImage(m, opt) 119 | err = tiff.Encode(buf, m, &tiff.Options{Compression: tiff.Deflate, Predictor: true}) 120 | if err != nil { 121 | return nil, err 122 | } 123 | default: 124 | return nil, fmt.Errorf("unsupported format: %v", format) 125 | } 126 | 127 | return buf.Bytes(), nil 128 | } 129 | 130 | // evaluateFloat interprets the option value f. If f is between 0 and 1, it is 131 | // interpreted as a percentage of max, otherwise it is treated as an absolute 132 | // value. If f is less than 0, 0 is returned. 133 | func evaluateFloat(f float64, max int) int { 134 | if 0 < f && f < 1 { 135 | return int(float64(max) * f) 136 | } 137 | if f < 0 { 138 | return 0 139 | } 140 | return int(f) 141 | } 142 | 143 | // resizeParams determines if the image needs to be resized, and if so, the 144 | // dimensions to resize to. 145 | func resizeParams(m image.Image, opt Options) (w, h int, resize bool) { 146 | // convert percentage width and height values to absolute values 147 | imgW := m.Bounds().Dx() 148 | imgH := m.Bounds().Dy() 149 | w = evaluateFloat(opt.Width, imgW) 150 | h = evaluateFloat(opt.Height, imgH) 151 | 152 | // never resize larger than the original image unless specifically allowed 153 | if !opt.ScaleUp { 154 | if w > imgW { 155 | w = imgW 156 | } 157 | if h > imgH { 158 | h = imgH 159 | } 160 | } 161 | 162 | // if requested width and height match the original, skip resizing 163 | if (w == imgW || w == 0) && (h == imgH || h == 0) { 164 | return 0, 0, false 165 | } 166 | 167 | return w, h, true 168 | } 169 | 170 | var smartcropAnalyzer = smartcrop.NewAnalyzer(nfnt.NewDefaultResizer()) 171 | 172 | // cropParams calculates crop rectangle parameters to keep it in image bounds 173 | func cropParams(m image.Image, opt Options) image.Rectangle { 174 | if !opt.SmartCrop && opt.CropX == 0 && opt.CropY == 0 && opt.CropWidth == 0 && opt.CropHeight == 0 { 175 | return m.Bounds() 176 | } 177 | 178 | // width and height of image 179 | imgW := m.Bounds().Dx() 180 | imgH := m.Bounds().Dy() 181 | 182 | if opt.SmartCrop { 183 | w := evaluateFloat(opt.Width, imgW) 184 | h := evaluateFloat(opt.Height, imgH) 185 | r, err := smartcropAnalyzer.FindBestCrop(m, w, h) 186 | if err != nil { 187 | log.Printf("smartcrop error finding best crop: %v", err) 188 | } else { 189 | return r 190 | } 191 | } 192 | 193 | // top left coordinate of crop 194 | x0 := evaluateFloat(math.Abs(opt.CropX), imgW) 195 | if opt.CropX < 0 { 196 | x0 = imgW - x0 // measure from right 197 | } 198 | y0 := evaluateFloat(math.Abs(opt.CropY), imgH) 199 | if opt.CropY < 0 { 200 | y0 = imgH - y0 // measure from bottom 201 | } 202 | 203 | // width and height of crop 204 | w := evaluateFloat(opt.CropWidth, imgW) 205 | if w == 0 { 206 | w = imgW 207 | } 208 | h := evaluateFloat(opt.CropHeight, imgH) 209 | if h == 0 { 210 | h = imgH 211 | } 212 | 213 | // bottom right coordinate of crop 214 | x1 := x0 + w 215 | if x1 > imgW { 216 | x1 = imgW 217 | } 218 | y1 := y0 + h 219 | if y1 > imgH { 220 | y1 = imgH 221 | } 222 | 223 | return image.Rect(x0, y0, x1, y1) 224 | } 225 | 226 | // read EXIF orientation tag from r and adjust opt to orient image correctly. 227 | func exifOrientation(r io.Reader) (opt Options) { 228 | // Exif Orientation Tag values 229 | // http://sylvana.net/jpegcrop/exif_orientation.html 230 | const ( 231 | topLeftSide = 1 232 | topRightSide = 2 233 | bottomRightSide = 3 234 | bottomLeftSide = 4 235 | leftSideTop = 5 236 | rightSideTop = 6 237 | rightSideBottom = 7 238 | leftSideBottom = 8 239 | ) 240 | 241 | ex, err := exif.Decode(r) 242 | if err != nil { 243 | return opt 244 | } 245 | tag, err := ex.Get(exif.Orientation) 246 | if err != nil { 247 | return opt 248 | } 249 | orient, err := tag.Int(0) 250 | if err != nil { 251 | return opt 252 | } 253 | 254 | switch orient { 255 | case topLeftSide: 256 | // do nothing 257 | case topRightSide: 258 | opt.FlipHorizontal = true 259 | case bottomRightSide: 260 | opt.Rotate = 180 261 | case bottomLeftSide: 262 | opt.FlipVertical = true 263 | case leftSideTop: 264 | opt.Rotate = 90 265 | opt.FlipVertical = true 266 | case rightSideTop: 267 | opt.Rotate = -90 268 | case rightSideBottom: 269 | opt.Rotate = 90 270 | opt.FlipHorizontal = true 271 | case leftSideBottom: 272 | opt.Rotate = 90 273 | } 274 | return opt 275 | } 276 | 277 | // transformImage modifies the image m based on the transformations specified 278 | // in opt. 279 | func transformImage(m image.Image, opt Options) image.Image { 280 | timer := prometheus.NewTimer(metricTransformationDuration) 281 | defer timer.ObserveDuration() 282 | 283 | // Parse crop and resize parameters before applying any transforms. 284 | // This is to ensure that any percentage-based values are based off the 285 | // size of the original image. 286 | rect := cropParams(m, opt) 287 | w, h, resize := resizeParams(m, opt) 288 | 289 | // crop if needed 290 | if !m.Bounds().Eq(rect) { 291 | m = imaging.Crop(m, rect) 292 | } 293 | // resize if needed 294 | if resize { 295 | if opt.Fit { 296 | m = imaging.Fit(m, w, h, resampleFilter) 297 | } else { 298 | if w == 0 || h == 0 { 299 | m = imaging.Resize(m, w, h, resampleFilter) 300 | } else { 301 | m = imaging.Thumbnail(m, w, h, resampleFilter) 302 | } 303 | } 304 | } 305 | 306 | // rotate 307 | rotate := float64(opt.Rotate) - math.Floor(float64(opt.Rotate)/360)*360 308 | switch rotate { 309 | case 90: 310 | m = imaging.Rotate90(m) 311 | case 180: 312 | m = imaging.Rotate180(m) 313 | case 270: 314 | m = imaging.Rotate270(m) 315 | } 316 | 317 | // flip 318 | if opt.FlipVertical { 319 | m = imaging.FlipV(m) 320 | } 321 | if opt.FlipHorizontal { 322 | m = imaging.FlipH(m) 323 | } 324 | 325 | return m 326 | } 327 | -------------------------------------------------------------------------------- /docs/plugin-design.md: -------------------------------------------------------------------------------- 1 | # Plugin Design Doc 2 | 3 | **Status:** idea phase, with no immediate timeline for implementation 4 | 5 | ## Objective 6 | 7 | Rearchitect imageproxy to use a plugin-based system for most features like 8 | transformations, security, and caching. This should reduce build times and 9 | binary sizes in the common case, and provide a mechanism for users to easily 10 | add custom features that would not be added to core for various reasons. 11 | 12 | ## Background 13 | 14 | I created imageproxy to [scratch a personal itch](https://wjn.me/b/J_), I 15 | needed a simple way to dynamically resize images for my personal website. I 16 | published it as an open source projects because that's what I do, and I'm happy 17 | to see others finding it useful for their needs as well. 18 | 19 | But inevitably, with more users came requests for additional features because 20 | people have different use cases and requirements. Some of these requests were 21 | relatively minor, and I was happy to add them. But one of the more common 22 | requests was to support different caching backends. Personally, I still use the 23 | on-disk cache, but many people wanted to use redis or a cloud provider like 24 | AWS, Azure, or GCP. For a long time I was resistant to adding support for 25 | these, mainly out of concern for inflating build times and binary sizes. I did 26 | eventually relent, and 27 | [#49](https://github.com/willnorris/imageproxy/issues/49) tracked adding 28 | support for the most common backends. 29 | 30 | Unfortunately my concerns proved true, and build times are *significantly* 31 | slower (TODO: add concrete numbers) now because of all the additional cloud 32 | SDKs that get compiled in. I don't personally care too much about binary size, 33 | since I'm not running in a constrained environment, but these build times are 34 | really wearing on me. Additionally, there are a number of outstanding pull 35 | requests for relatively obscure features that I don't really want to have to 36 | support in the main project. And quite honestly, there are a number of obscure 37 | features that did get merged in over the years that I kinda wish I could rip 38 | back out. 39 | 40 | ### Plugin support in Go 41 | 42 | TODO: talk about options like 43 | - RPC (https://github.com/hashicorp/go-plugin) 44 | - pkg/plugin (https://golang.org/pkg/plugin/) 45 | - embedded interpreter (https://github.com/robertkrimen/otto) 46 | - custom binaries (https://github.com/mholt/caddy, 47 | https://caddy.community/t/59) 48 | 49 | Spoiler: I'm planning on following the Caddy approach and using custom 50 | binaries. 51 | 52 | ## Design 53 | 54 | I plan to model imageproxy after Caddy, moving all key functionality into 55 | separate plugins that register themselves with the server, and which all 56 | compile to a single statically-linked binary. The core project will provide a 57 | great number of plugins to cover all of the existing functionality. I also 58 | expect I'll be much more open to adding plugins for features I may not care as 59 | much about personally. Of course, users can also write their own plugins and 60 | link them in without needing to contribute them to core if they don't want to. 61 | 62 | I anticipate providing two or three build configurations in core: 63 | - **full** - include all the plugins that are part of core (except where they 64 | may conflict) 65 | - **minimal** - some set of minimal features that only includes basic caching 66 | options, limited transformation options, etc 67 | - **my personal config** - I'll also definitely have a build that I use 68 | personally on my site. I may decide to just make that the "minimal" build 69 | and perhaps call it something different, rather than have a third 70 | configuration. 71 | 72 | Custom configurations beyond what is provided by core can be done by creating a 73 | minimal main package that imports the plugins you care about and calling some 74 | kind of bootstrap method (similar to [what Caddy now 75 | does](https://caddy.community/t/59)). 76 | 77 | ### Types of plugins 78 | 79 | (Initially in no particular order, just capturing thoughts. Lots to do here in 80 | thinking through the use cases and what kind of plugin API we really need to 81 | provide.) 82 | 83 | See also issues and PRs with [label:plugins][]. 84 | 85 | [label:plugins]: https://github.com/willnorris/imageproxy/issues?q=label:plugins 86 | 87 | #### Caching backend 88 | 89 | This is one of the most common feature requests, and is also one of the worst 90 | offender for inflating build times and binary sizes because of the size of the 91 | dependencies that are typically required. The minimal imageproxy build would 92 | probably only include the in-memory and on-disk caches. Anything that talked to 93 | an external store (redis, cloud providers, etc) would be pulled out. 94 | 95 | #### Transformation engine 96 | 97 | Today, imageproxy only performs transformations which can be done with pure Go 98 | libraries. There have been a number of requests (or at least questions) to use 99 | something like [vips](https://github.com/DAddYE/vips) or 100 | [imagemagick](https://github.com/gographics/imagick), which are both C 101 | libraries. They provide more options, and (likely) better performance, at the 102 | cost of complexity and loss of portability in using cgo. These would likely 103 | replace the entire transformation engine in imageproxy, so I don't know how 104 | they would interact with other plugins that merely extend the main engine (they 105 | probably wouldn't be able to interact at all). 106 | 107 | #### Transformation options 108 | 109 | Today, imageproxy performs minimal transformations, mostly around resizing, 110 | cropping, and rotation. It doesn't support any kind of filters, brightness or 111 | contrast adjustment, etc. There are go libraries for them, they're just outside 112 | the scope of what I originally intended imageproxy for. But I'd be happy to 113 | have plugins that do that kind of thing. These plugins would need to be able to 114 | hook into the option parsing engine so that they could register their URL 115 | options. 116 | 117 | #### Image format support 118 | 119 | There have been a number of requests for imge format support that require cgo 120 | libraries: 121 | 122 | - **webp encoding** - needs cgo 123 | [#114](https://github.com/willnorris/imageproxy/issues/114) 124 | - **progressive jpegs** - probably needs cgo? 125 | [#77](https://github.com/willnorris/imageproxy/issues/77) 126 | - **gif to mp4** - maybe doable in pure go, but probably belongs in a plugin 127 | [#136](https://github.com/willnorris/imageproxy/issues/136) 128 | - **HEIF** - formate used by newer iPhones 129 | ([HEIF](https://en.wikipedia.org/wiki/High_Efficiency_Image_File_Format)) 130 | 131 | #### Option parsing 132 | 133 | Today, options are specified as the first component in the URL path, but 134 | [#66](https://github.com/willnorris/imageproxy/pull/66) proposes optionally 135 | moving that to a query parameter (for a good reason, actually). Maybe putting 136 | that in core is okay? Maybe it belongs in a plugin, in which case we'd need to 137 | expose an API for replacing the option parsing code entirely. 138 | 139 | #### Security options 140 | 141 | Some people want to add a host blacklist 142 | [#85](https://github.com/willnorris/imageproxy/pull/85), refusal to process 143 | non-image files [#53](https://github.com/willnorris/imageproxy/issues/53) 144 | [#119](https://github.com/willnorris/imageproxy/pull/119). I don't think there 145 | is an issue for it, but an early fork of the project added request signing that 146 | was compatible with nginx's [secure link 147 | module](https://nginx.org/en/docs/http/ngx_http_secure_link_module.html). 148 | 149 | ### Registering Plugins 150 | 151 | Plugins are loaded simply by importing their package. They should have an 152 | `init` func that calls `imageproxy.RegisterPlugin`: 153 | 154 | ``` go 155 | type Plugin struct { 156 | } 157 | 158 | func RegisterPlugin(name string, plugin Plugin) 159 | ``` 160 | 161 | Plugins hook into various extension points of imageproxy by implementing 162 | appropriate interfaces. A single plugin can hook into multiple parts of 163 | imageproxy by implementing multiple interfaces. 164 | 165 | For example, two possible interfaces for security related plugins: 166 | 167 | ``` go 168 | // A RequestAuthorizer determines if a request is authorized to be processed. 169 | // Requests are processed before the remote resource is retrieved. 170 | type RequestAuthorizer interface { 171 | // Authorize returns an error if the request should not 172 | // be processed further (for example, it doesn't have a 173 | // valid signature, is not for a whitelisted host, etc). 174 | AuthorizeRequest(req *http.Request) error 175 | } 176 | 177 | // A ResponseAuthorizer determines if a response from a remote server 178 | // is authorized to be returned. 179 | type ResponseAuthorizer interface { 180 | // AuthorizeResponse returns an error if a response should not be 181 | // returned to a client (for example, it is not for an image 182 | // resource, etc). 183 | AuthorizeResponse(res http.Response) error 184 | } 185 | ``` 186 | 187 | A hypothetical interface for plugins that transform images: 188 | 189 | ``` go 190 | // An ImageTransformer transforms an image. 191 | type ImageTransformer interface { 192 | // TransformImage based on the provided options and return the result. 193 | TransformImage(m image.Image, opt Options) image.Image 194 | } 195 | ``` 196 | 197 | Plugins are additionally responsible for registering any additional command 198 | line flags they wish to expose to the user, as well as storing any global state 199 | that would previously have been stored on the Proxy struct. 200 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | -------------------------------------------------------------------------------- /data.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "regexp" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | ) 15 | 16 | const ( 17 | optFit = "fit" 18 | optFlipVertical = "fv" 19 | optFlipHorizontal = "fh" 20 | optFormatJPEG = "jpeg" 21 | optFormatPNG = "png" 22 | optFormatTIFF = "tiff" 23 | optRotatePrefix = "r" 24 | optQualityPrefix = "q" 25 | optSignaturePrefix = "s" 26 | optSizeDelimiter = "x" 27 | optScaleUp = "scaleUp" 28 | optCropX = "cx" 29 | optCropY = "cy" 30 | optCropWidth = "cw" 31 | optCropHeight = "ch" 32 | optSmartCrop = "sc" 33 | ) 34 | 35 | // URLError reports a malformed URL error. 36 | type URLError struct { 37 | Message string 38 | URL *url.URL 39 | } 40 | 41 | func (e URLError) Error() string { 42 | return fmt.Sprintf("malformed URL %q: %s", e.URL, e.Message) 43 | } 44 | 45 | // Options specifies transformations to be performed on the requested image. 46 | type Options struct { 47 | // See ParseOptions for interpretation of Width and Height values 48 | Width float64 49 | Height float64 50 | 51 | // If true, resize the image to fit in the specified dimensions. Image 52 | // will not be cropped, and aspect ratio will be maintained. 53 | Fit bool 54 | 55 | // Rotate image the specified degrees counter-clockwise. Valid values 56 | // are 90, 180, 270. 57 | Rotate int 58 | 59 | FlipVertical bool 60 | FlipHorizontal bool 61 | 62 | // Quality of output image 63 | Quality int 64 | 65 | // HMAC Signature for signed requests. 66 | Signature string 67 | 68 | // Allow image to scale beyond its original dimensions. This value 69 | // will always be overwritten by the value of Proxy.ScaleUp. 70 | ScaleUp bool 71 | 72 | // Desired image format. Valid values are "jpeg", "png", "tiff". 73 | Format string 74 | 75 | // Crop rectangle params 76 | CropX float64 77 | CropY float64 78 | CropWidth float64 79 | CropHeight float64 80 | 81 | // Automatically find good crop points based on image content. 82 | SmartCrop bool 83 | } 84 | 85 | func (o Options) String() string { 86 | opts := []string{fmt.Sprintf("%v%s%v", o.Width, optSizeDelimiter, o.Height)} 87 | if o.Fit { 88 | opts = append(opts, optFit) 89 | } 90 | if o.Rotate != 0 { 91 | opts = append(opts, fmt.Sprintf("%s%d", optRotatePrefix, o.Rotate)) 92 | } 93 | if o.FlipVertical { 94 | opts = append(opts, optFlipVertical) 95 | } 96 | if o.FlipHorizontal { 97 | opts = append(opts, optFlipHorizontal) 98 | } 99 | if o.Quality != 0 { 100 | opts = append(opts, fmt.Sprintf("%s%d", optQualityPrefix, o.Quality)) 101 | } 102 | if o.Signature != "" { 103 | opts = append(opts, fmt.Sprintf("%s%s", optSignaturePrefix, o.Signature)) 104 | } 105 | if o.ScaleUp { 106 | opts = append(opts, optScaleUp) 107 | } 108 | if o.Format != "" { 109 | opts = append(opts, o.Format) 110 | } 111 | if o.CropX != 0 { 112 | opts = append(opts, fmt.Sprintf("%s%v", optCropX, o.CropX)) 113 | } 114 | if o.CropY != 0 { 115 | opts = append(opts, fmt.Sprintf("%s%v", optCropY, o.CropY)) 116 | } 117 | if o.CropWidth != 0 { 118 | opts = append(opts, fmt.Sprintf("%s%v", optCropWidth, o.CropWidth)) 119 | } 120 | if o.CropHeight != 0 { 121 | opts = append(opts, fmt.Sprintf("%s%v", optCropHeight, o.CropHeight)) 122 | } 123 | if o.SmartCrop { 124 | opts = append(opts, optSmartCrop) 125 | } 126 | sort.Strings(opts) 127 | return strings.Join(opts, ",") 128 | } 129 | 130 | // transform returns whether o includes transformation options. Some fields 131 | // are not transform related at all (like Signature), and others only apply in 132 | // the presence of other fields (like Fit). A non-empty Format value is 133 | // assumed to involve a transformation. 134 | func (o Options) transform() bool { 135 | return o.Width != 0 || o.Height != 0 || o.Rotate != 0 || o.FlipHorizontal || o.FlipVertical || o.Quality != 0 || o.Format != "" || o.CropX != 0 || o.CropY != 0 || o.CropWidth != 0 || o.CropHeight != 0 136 | } 137 | 138 | // ParseOptions parses str as a list of comma separated transformation options. 139 | // The options can be specified in in order, with duplicate options overwriting 140 | // previous values. 141 | // 142 | // Rectangle Crop 143 | // 144 | // There are four options controlling rectangle crop: 145 | // 146 | // cx{x} - X coordinate of top left rectangle corner (default: 0) 147 | // cy{y} - Y coordinate of top left rectangle corner (default: 0) 148 | // cw{width} - rectangle width (default: image width) 149 | // ch{height} - rectangle height (default: image height) 150 | // 151 | // For all options, integer values are interpreted as exact pixel values and 152 | // floats between 0 and 1 are interpreted as percentages of the original image 153 | // size. Negative values for cx and cy are measured from the right and bottom 154 | // edges of the image, respectively. 155 | // 156 | // If the crop width or height exceed the width or height of the image, the 157 | // crop width or height will be adjusted, preserving the specified cx and cy 158 | // values. Rectangular crop is applied before any other transformations. 159 | // 160 | // Smart Crop 161 | // 162 | // The "sc" option will perform a content-aware smart crop to fit the 163 | // requested image width and height dimensions (see Size and Cropping below). 164 | // The smart crop option will override any requested rectangular crop. 165 | // 166 | // Size and Cropping 167 | // 168 | // The size option takes the general form "{width}x{height}", where width and 169 | // height are numbers. Integer values greater than 1 are interpreted as exact 170 | // pixel values. Floats between 0 and 1 are interpreted as percentages of the 171 | // original image size. If either value is omitted or set to 0, it will be 172 | // automatically set to preserve the aspect ratio based on the other dimension. 173 | // If a single number is provided (with no "x" separator), it will be used for 174 | // both height and width. 175 | // 176 | // Depending on the size options specified, an image may be cropped to fit the 177 | // requested size. In all cases, the original aspect ratio of the image will be 178 | // preserved; imageproxy will never stretch the original image. 179 | // 180 | // When no explicit crop mode is specified, the following rules are followed: 181 | // 182 | // - If both width and height values are specified, the image will be scaled to 183 | // fill the space, cropping if necessary to fit the exact dimension. 184 | // 185 | // - If only one of the width or height values is specified, the image will be 186 | // resized to fit the specified dimension, scaling the other dimension as 187 | // needed to maintain the aspect ratio. 188 | // 189 | // If the "fit" option is specified together with a width and height value, the 190 | // image will be resized to fit within a containing box of the specified size. 191 | // As always, the original aspect ratio will be preserved. Specifying the "fit" 192 | // option with only one of either width or height does the same thing as if 193 | // "fit" had not been specified. 194 | // 195 | // Rotation and Flips 196 | // 197 | // The "r{degrees}" option will rotate the image the specified number of 198 | // degrees, counter-clockwise. Valid degrees values are 90, 180, and 270. 199 | // 200 | // The "fv" option will flip the image vertically. The "fh" option will flip 201 | // the image horizontally. Images are flipped after being rotated. 202 | // 203 | // Quality 204 | // 205 | // The "q{qualityPercentage}" option can be used to specify the quality of the 206 | // output file (JPEG only). If not specified, the default value of "95" is used. 207 | // 208 | // Format 209 | // 210 | // The "jpeg", "png", and "tiff" options can be used to specify the desired 211 | // image format of the proxied image. 212 | // 213 | // Signature 214 | // 215 | // The "s{signature}" option specifies an optional base64 encoded HMAC used to 216 | // sign the remote URL in the request. The HMAC key used to verify signatures is 217 | // provided to the imageproxy server on startup. 218 | // 219 | // See https://github.com/willnorris/imageproxy/blob/master/docs/url-signing.md 220 | // for examples of generating signatures. 221 | // 222 | // Examples 223 | // 224 | // 0x0 - no resizing 225 | // 200x - 200 pixels wide, proportional height 226 | // x0.15 - 15% original height, proportional width 227 | // 100x150 - 100 by 150 pixels, cropping as needed 228 | // 100 - 100 pixels square, cropping as needed 229 | // 150,fit - scale to fit 150 pixels square, no cropping 230 | // 100,r90 - 100 pixels square, rotated 90 degrees 231 | // 100,fv,fh - 100 pixels square, flipped horizontal and vertical 232 | // 200x,q60 - 200 pixels wide, proportional height, 60% quality 233 | // 200x,png - 200 pixels wide, converted to PNG format 234 | // cw100,ch100 - crop image to 100px square, starting at (0,0) 235 | // cx10,cy20,cw100,ch200 - crop image starting at (10,20) is 100px wide and 200px tall 236 | func ParseOptions(str string) Options { 237 | var options Options 238 | 239 | for _, opt := range strings.Split(str, ",") { 240 | switch { 241 | case len(opt) == 0: // do nothing 242 | case opt == optFit: 243 | options.Fit = true 244 | case opt == optFlipVertical: 245 | options.FlipVertical = true 246 | case opt == optFlipHorizontal: 247 | options.FlipHorizontal = true 248 | case opt == optScaleUp: // this option is intentionally not documented above 249 | options.ScaleUp = true 250 | case opt == optFormatJPEG, opt == optFormatPNG, opt == optFormatTIFF: 251 | options.Format = opt 252 | case opt == optSmartCrop: 253 | options.SmartCrop = true 254 | case strings.HasPrefix(opt, optRotatePrefix): 255 | value := strings.TrimPrefix(opt, optRotatePrefix) 256 | options.Rotate, _ = strconv.Atoi(value) 257 | case strings.HasPrefix(opt, optQualityPrefix): 258 | value := strings.TrimPrefix(opt, optQualityPrefix) 259 | options.Quality, _ = strconv.Atoi(value) 260 | case strings.HasPrefix(opt, optSignaturePrefix): 261 | options.Signature = strings.TrimPrefix(opt, optSignaturePrefix) 262 | case strings.HasPrefix(opt, optCropX): 263 | value := strings.TrimPrefix(opt, optCropX) 264 | options.CropX, _ = strconv.ParseFloat(value, 64) 265 | case strings.HasPrefix(opt, optCropY): 266 | value := strings.TrimPrefix(opt, optCropY) 267 | options.CropY, _ = strconv.ParseFloat(value, 64) 268 | case strings.HasPrefix(opt, optCropWidth): 269 | value := strings.TrimPrefix(opt, optCropWidth) 270 | options.CropWidth, _ = strconv.ParseFloat(value, 64) 271 | case strings.HasPrefix(opt, optCropHeight): 272 | value := strings.TrimPrefix(opt, optCropHeight) 273 | options.CropHeight, _ = strconv.ParseFloat(value, 64) 274 | case strings.Contains(opt, optSizeDelimiter): 275 | size := strings.SplitN(opt, optSizeDelimiter, 2) 276 | if w := size[0]; w != "" { 277 | options.Width, _ = strconv.ParseFloat(w, 64) 278 | } 279 | if h := size[1]; h != "" { 280 | options.Height, _ = strconv.ParseFloat(h, 64) 281 | } 282 | default: 283 | if size, err := strconv.ParseFloat(opt, 64); err == nil { 284 | options.Width = size 285 | options.Height = size 286 | } 287 | } 288 | } 289 | 290 | return options 291 | } 292 | 293 | // Request is an imageproxy request which includes a remote URL of an image to 294 | // proxy, and an optional set of transformations to perform. 295 | type Request struct { 296 | URL *url.URL // URL of the image to proxy 297 | Options Options // Image transformation to perform 298 | Original *http.Request // The original HTTP request 299 | } 300 | 301 | // String returns the request URL as a string, with r.Options encoded in the 302 | // URL fragment. 303 | func (r Request) String() string { 304 | u := *r.URL 305 | u.Fragment = r.Options.String() 306 | return u.String() 307 | } 308 | 309 | // NewRequest parses an http.Request into an imageproxy Request. Options and 310 | // the remote image URL are specified in the request path, formatted as: 311 | // /{options}/{remote_url}. Options may be omitted, so a request path may 312 | // simply contain /{remote_url}. The remote URL must be an absolute "http" or 313 | // "https" URL, should not be URL encoded, and may contain a query string. 314 | // 315 | // Assuming an imageproxy server running on localhost, the following are all 316 | // valid imageproxy requests: 317 | // 318 | // http://localhost/100x200/http://example.com/image.jpg 319 | // http://localhost/100x200,r90/http://example.com/image.jpg?foo=bar 320 | // http://localhost//http://example.com/image.jpg 321 | // http://localhost/http://example.com/image.jpg 322 | func NewRequest(r *http.Request, baseURL *url.URL) (*Request, error) { 323 | var err error 324 | req := &Request{Original: r} 325 | 326 | path := r.URL.EscapedPath()[1:] // strip leading slash 327 | req.URL, err = parseURL(path) 328 | if err != nil || !req.URL.IsAbs() { 329 | // first segment should be options 330 | parts := strings.SplitN(path, "/", 2) 331 | if len(parts) != 2 { 332 | return nil, URLError{"too few path segments", r.URL} 333 | } 334 | 335 | var err error 336 | req.URL, err = parseURL(parts[1]) 337 | if err != nil { 338 | return nil, URLError{fmt.Sprintf("unable to parse remote URL: %v", err), r.URL} 339 | } 340 | 341 | req.Options = ParseOptions(parts[0]) 342 | } 343 | 344 | if baseURL != nil { 345 | req.URL = baseURL.ResolveReference(req.URL) 346 | } 347 | 348 | if !req.URL.IsAbs() { 349 | return nil, URLError{"must provide absolute remote URL", r.URL} 350 | } 351 | 352 | if req.URL.Scheme != "http" && req.URL.Scheme != "https" { 353 | return nil, URLError{"remote URL must have http or https scheme", r.URL} 354 | } 355 | 356 | // query string is always part of the remote URL 357 | req.URL.RawQuery = r.URL.RawQuery 358 | return req, nil 359 | } 360 | 361 | var reCleanedURL = regexp.MustCompile(`^(https?):/+([^/])`) 362 | 363 | // parseURL parses s as a URL, handling URLs that have been munged by 364 | // path.Clean or a webserver that collapses multiple slashes. 365 | func parseURL(s string) (*url.URL, error) { 366 | s = reCleanedURL.ReplaceAllString(s, "$1://$2") 367 | return url.Parse(s) 368 | } 369 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This file contains all notable changes to 4 | [imageproxy](https://github.com/willnorris/imageproxy). The format is based on 5 | [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project 6 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | [Unreleased]: https://github.com/willnorris/imageproxy/compare/v0.9.0...HEAD 10 | 11 | ## [0.10.0] (2020-04-02) 12 | [0.10.0]: https://github.com/willnorris/imageproxy/compare/v0.9.0...v0.10.0 13 | 14 | ### Added 15 | - add support for multiple signature keys to support key rotation 16 | ([ef09c1b](https://github.com/willnorris/imageproxy/commit/ef09c1b), 17 | [#209](https://github.com/willnorris/imageproxy/pull/209), 18 | [maurociancio](https://github.com/maurociancio)) 19 | - added option to include referer header in remote requests 20 | ([#216](https://github.com/willnorris/imageproxy/issues/216)) 21 | - added basic support for recording prometheus metrics 22 | ([#121](https://github.com/willnorris/imageproxy/pull/121) 23 | [benhaan](https://github.com/benhaan)) 24 | 25 | ### Fixed 26 | - improved content type detection for some hosts, particularly S3 27 | ([ea95ad9](https://github.com/willnorris/imageproxy/commit/ea95ad9), 28 | [shahan312](https://github.com/shahan312)) 29 | - fix signature verification for some proxied URLs 30 | ([3589510](https://github.com/willnorris/imageproxy/commit/3589510), 31 | [#212](https://github.com/willnorris/imageproxy/issues/212), 32 | ([#215](https://github.com/willnorris/imageproxy/issues/215), 33 | thanks to [aaronpk](https://github.com/aaronpk) for helping debug and 34 | [fieldistor](https://github.com/fieldistor) for the suggested fix) 35 | 36 | ## [0.9.0] (2019-06-10) 37 | [0.9.0]: https://github.com/willnorris/imageproxy/compare/v0.8.0...v0.9.0 38 | 39 | ### Added 40 | - allow request signatures to cover options 41 | ([#145](https://github.com/willnorris/imageproxy/issues/145)) 42 | - add simple imageproxy-sign tool for calculating signatures 43 | ([e1558d5](https://github.com/willnorris/imageproxy/commit/e1558d5)) 44 | - allow overriding the Logger used by Proxy 45 | ([#174](https://github.com/willnorris/imageproxy/pull/174), 46 | [hmhealey](https://github.com/hmhealey)) 47 | - allow using environment variables for configuration 48 | ([50e0d11](https://github.com/willnorris/imageproxy/commit/50e0d11)) 49 | - add support for BMP images 50 | ([d4ba520](https://github.com/willnorris/imageproxy/commit/d4ba520)) 51 | 52 | ### Changed 53 | - improvements to docker image: run as non-privileged user, use go1.12 54 | compiler, and build imageproxy as a go module. 55 | 56 | - options are now sorted when converting to string. This is a breaking change 57 | for anyone relying on the option order, and will additionally invalidate 58 | most cached values, since the option string is part of the cache key. 59 | 60 | Both the original remote image, as well as any transformations on that image 61 | are cached, but only the transformed images will be impacted by this change. 62 | This will result in imageproxy having to re-perform the transformations, but 63 | should not result in re-fetching the remote image, unless it has already 64 | otherwise expired. 65 | 66 | ### Fixed 67 | - properly include Accept header on remote URL requests 68 | ([#165](https://github.com/willnorris/imageproxy/issues/165), 69 | [6aca1e0](https://github.com/willnorris/imageproxy/commit/6aca1e0)) 70 | - detect response content type if content-type header is missing 71 | ([cf54b2c](https://github.com/willnorris/imageproxy/commit/cf54b2c)) 72 | 73 | ### Removed 74 | - removed deprecated `whitelist` flag and `Proxy.Whitelist` struct field. Use 75 | `allowHosts` and `Proxy.AllowHosts` instead. 76 | 77 | ## [0.8.0] (2019-03-21) 78 | [0.8.0]: https://github.com/willnorris/imageproxy/compare/v0.7.0...v0.8.0 79 | 80 | ### Added 81 | - added support for restricting proxied URLs [based on Content-Type 82 | headers](https://github.com/willnorris/imageproxy#allowed-content-type-list) 83 | ([#141](https://github.com/willnorris/imageproxy/pull/141), 84 | [ccbrown](https://github.com/ccbrown)) 85 | - added ability to [deny requests](https://github.com/willnorris/imageproxy#allowed-and-denied-hosts-list) 86 | for certain remote hosts 87 | ([#85](https://github.com/willnorris/imageproxy/pull/85), 88 | [geriljaSA](https://github.com/geriljaSA)) 89 | - added `userAgent` flag to specify a custom user agent when fetching images 90 | ([#83](https://github.com/willnorris/imageproxy/pull/83), 91 | [huguesalary](https://github.com/huguesalary)) 92 | - added support for [s3 compatible](https://github.com/willnorris/imageproxy#cache) 93 | storage providers 94 | ([#147](https://github.com/willnorris/imageproxy/pull/147), 95 | [ruledio](https://github.com/ruledio)) 96 | - log URL when image transform fails for easier debugging 97 | ([#149](https://github.com/willnorris/imageproxy/pull/149), 98 | [daohoangson](https://github.com/daohoangson)) 99 | - added support for building imageproxy as a [go module](https://golang.org/wiki/Modules). 100 | A future version will remove vendored dependencies, at which point building 101 | as a module will be the only supported method of building imageproxy. 102 | 103 | ### Changed 104 | - when a remote URL is denied, return a generic error message that does not specify exactly why it failed 105 | ([7e19b5c](https://github.com/willnorris/imageproxy/commit/7e19b5c)) 106 | 107 | ### Deprecated 108 | - `whitelist` flag and `Proxy.Whitelist` struct field renamed to `allowHosts` 109 | and `Proxy.AllowHosts`. Old values are still supported, but will be removed 110 | in a future release. 111 | 112 | ### Fixed 113 | - fixed tcp_mem resource leak on 304 responses 114 | ([#153](https://github.com/willnorris/imageproxy/pull/153), 115 | [Micr0mega](https://github.com/Micr0mega)) 116 | 117 | ## [0.7.0] (2018-02-06) 118 | [0.7.0]: https://github.com/willnorris/imageproxy/compare/v0.6.0...v0.7.0 119 | 120 | ### Added 121 | - added support for arbitrary [rectangular crops](https://godoc.org/willnorris.com/go/imageproxy#hdr-Rectangle_Crop) 122 | ([#90](https://github.com/willnorris/imageproxy/pull/90), 123 | [maciejtarnowski](https://github.com/maciejtarnowski)) 124 | - added support for tiff images 125 | ([#109](https://github.com/willnorris/imageproxy/pull/109), 126 | [mikecx](https://github.com/mikecx)) 127 | - added support for additional [caching backends](https://github.com/willnorris/imageproxy#cache): 128 | - Google Cloud Storage 129 | ([#106](https://github.com/willnorris/imageproxy/pull/106), 130 | [diegomarangoni](https://github.com/diegomarangoni)) 131 | - Azure 132 | ([#79](https://github.com/willnorris/imageproxy/pull/79), 133 | [PaulARoy](https://github.com/PaulARoy)) 134 | - Redis 135 | ([#49](https://github.com/willnorris/imageproxy/issues/49) 136 | [dbfc693](https://github.com/willnorris/imageproxy/commit/dbfc693)) 137 | - Tiering multiple caches by repeating the `-cache` flag 138 | ([ec5b543](https://github.com/willnorris/imageproxy/commit/ec5b543)) 139 | - added support for EXIF orientation tags 140 | ([#63](https://github.com/willnorris/imageproxy/issues/63), 141 | [67619a6](https://github.com/willnorris/imageproxy/commit/67619a6)) 142 | - added [smart crop feature](https://godoc.org/willnorris.com/go/imageproxy#hdr-Smart_Crop) 143 | ([#55](https://github.com/willnorris/imageproxy/issues/55), 144 | [afbd254](https://github.com/willnorris/imageproxy/commit/afbd254)) 145 | 146 | ### Changed 147 | - rotate values are normalized, such that `r-90` is the same as `r270` 148 | ([07c54b4](https://github.com/willnorris/imageproxy/commit/07c54b4)) 149 | - now return `200 OK` response for requests to root `/` 150 | ([5ee7e28](https://github.com/willnorris/imageproxy/commit/5ee7e28)) 151 | - switch to using official AWS Go SDK for s3 cache storage. This is a 152 | breaking change for anyone using that cache implementation, since the URL 153 | syntax has changed. This adds support for the newer v4 auth method, as well 154 | as additional s3 regions. 155 | ([0ee5167](https://github.com/willnorris/imageproxy/commit/0ee5167)) 156 | - switched to standard go log library. Added `-verbose` flag for more logging 157 | in-memory cache backend supports limiting the max cache size 158 | ([a57047f](https://github.com/willnorris/imageproxy/commit/a57047f)) 159 | - docker image sized reduced by using scratch image and multistage build 160 | ([#113](https://github.com/willnorris/imageproxy/pull/113), 161 | [matematik7](https://github.com/matematik7)) 162 | 163 | ### Removed 164 | - removed deprecated `cacheDir` and `cacheSize` flags 165 | 166 | ### Fixed 167 | - fixed interpretation of `Last-Modified` and `If-Modified-Since` headers 168 | ([#108](https://github.com/willnorris/imageproxy/pull/108), 169 | [jamesreggio](https://github.com/jamesreggio)) 170 | - preserve original URL encoding 171 | ([#115](https://github.com/willnorris/imageproxy/issues/115)) 172 | 173 | ## [0.6.0] (2017-08-29) 174 | [0.6.0]: https://github.com/willnorris/imageproxy/compare/v0.5.1...v0.6.0 175 | 176 | ### Added 177 | - added health check endpoint 178 | ([#54](https://github.com/willnorris/imageproxy/pull/54), 179 | [immunda](https://github.com/immunda)) 180 | - preserve Link headers from remote image 181 | ([#68](https://github.com/willnorris/imageproxy/pull/68), 182 | [xavren](https://github.com/xavren)) 183 | - added support for per-request timeout 184 | ([#75](https://github.com/willnorris/imageproxy/issues/75)) 185 | - added support for specifying output image format 186 | ([b9cc9df](https://github.com/willnorris/imageproxy/commit/b9cc9df)) 187 | - added webp support (decode only) 188 | ([3280445](https://github.com/willnorris/imageproxy/commit/3280445)) 189 | - added CORS support 190 | ([#96](https://github.com/willnorris/imageproxy/pull/96), 191 | [romdim](https://github.com/romdim)) 192 | 193 | ### Fixed 194 | - improved error messages for some authorization failures 195 | ([27d5378](https://github.com/willnorris/imageproxy/commit/27d5378)) 196 | - skip transformation when not needed 197 | ([#64](https://github.com/willnorris/imageproxy/issues/64)) 198 | - properly handled "cleaned" remote URLs 199 | ([a1af9aa](https://github.com/willnorris/imageproxy/commit/a1af9aa), 200 | [b61992e](https://github.com/willnorris/imageproxy/commit/b61992e)) 201 | 202 | ## [0.5.1] (2015-12-07) 203 | [0.5.1]: https://github.com/willnorris/imageproxy/compare/v0.5.0...v0.5.1 204 | 205 | ### Fixed 206 | - fixed bug in gif resizing 207 | ([gifresize@104a7cd](https://github.com/willnorris/gifresize/commit/104a7cd)) 208 | 209 | ## [0.5.0] (2015-12-07) 210 | [0.5.0]: https://github.com/willnorris/imageproxy/compare/v0.4.0...v0.5.0 211 | 212 | ## Added 213 | - added Dockerfile 214 | ([#29](https://github.com/willnorris/imageproxy/pull/29), 215 | [sevki](https://github.com/sevki)) 216 | - allow scaling image beyond its original size with `-scaleUp` flag 217 | ([#37](https://github.com/willnorris/imageproxy/pull/37), 218 | [runemadsen](https://github.com/runemadsen)) 219 | - add ability to restrict HTTP referrer 220 | ([9213c93](https://github.com/willnorris/imageproxy/commit/9213c93), 221 | [connor4312](https://github.com/connor4312)) 222 | - preserve cache-control header from remote image 223 | ([#43](https://github.com/willnorris/imageproxy/pull/43), 224 | [runemadsen](https://github.com/runemadsen)) 225 | - add support for caching images on Amazon S3 226 | ([ec96fcb](https://github.com/willnorris/imageproxy/commit/ec96fcb) 227 | [victortrac](https://github.com/victortrac)) 228 | 229 | ## Changed 230 | - change default cache to none, and add `-cache` flag for specifying caches. 231 | This deprecates the `-cacheDir` flag. 232 | - on-disk cache now stores files in a two-level trie. For example, for a file 233 | named "c0ffee", store file as "c0/ff/c0ffee". 234 | 235 | ## Fixed 236 | - skip resizing if requested dimensions larger than original 237 | ([#46](https://github.com/willnorris/imageproxy/pull/46), 238 | [orian](https://github.com/orian)) 239 | 240 | ## [0.4.0] (2015-05-21) 241 | [0.4.0]: https://github.com/willnorris/imageproxy/compare/v0.3.0...v0.4.0 242 | 243 | ### Added 244 | - added support for animated gifs 245 | ([#23](https://github.com/willnorris/imageproxy/issues/23)) 246 | 247 | ### Changed 248 | - non-200 responses from remote servers are proxied as-is 249 | 250 | ## [0.3.0] (2015-12-07) 251 | [0.3.0]: https://github.com/willnorris/imageproxy/compare/v0.2.3...v0.3.0 252 | 253 | ### Added 254 | - added support for signing requests using a sha-256 HMAC. 255 | ([a9efefc](https://github.com/willnorris/imageproxy/commit/a9efefc)) 256 | - more complete logging of requests and whether response is from the cache 257 | ([#17](https://github.com/willnorris/imageproxy/issues/17)) 258 | - added support for a base URL for remote images. This allows shorter relative 259 | URLs to be specified in requests. 260 | ([#15](https://github.com/willnorris/imageproxy/issues/15)) 261 | 262 | ### Fixed 263 | - be more precise in copying over all headers from remote image response 264 | ([1bf0515](https://github.com/willnorris/imageproxy/commit/1bf0515)) 265 | 266 | ## [0.2.3] (2015-02-20) 267 | [0.2.3]: https://github.com/willnorris/imageproxy/compare/v0.2.2...v0.2.3 268 | 269 | ### Added 270 | - added quality option 271 | ([#13](https://github.com/willnorris/imageproxy/pull/13) 272 | [cubabit](https://github.com/cubabit)) 273 | 274 | ## [0.2.2] (2014-12-08) 275 | [0.2.2]: https://github.com/willnorris/imageproxy/compare/v0.2.1...v0.2.2 276 | 277 | ### Added 278 | - added `cacheSize` flag to command line 279 | 280 | ### Changed 281 | - improved documentation and error messages 282 | - negative width or height transformation values interpreted as 0 283 | 284 | ## [0.2.1] (2014-08-13) 285 | [0.2.1]: https://github.com/willnorris/imageproxy/compare/v0.2.0...v0.2.1 286 | 287 | ### Changed 288 | - restructured package so that the command line tools is now installed from 289 | `willnorris.com/go/imageproxy/cmd/imageproxy` 290 | 291 | ## [0.2.0] (2014-07-02) 292 | [0.2.0]: https://github.com/willnorris/imageproxy/compare/v0.1.0...v0.2.0 293 | 294 | ### Added 295 | - transformed images are cached in addition to the original image 296 | ([#1](https://github.com/willnorris/imageproxy/issues/1)) 297 | - support etag and last-modified headers on incoming requests 298 | ([#3](https://github.com/willnorris/imageproxy/issues/3)) 299 | - support wildcards in list of allowed hosts 300 | 301 | ### Changed 302 | - options can be specified in any order 303 | - images cannot be resized larger than their original dimensions 304 | 305 | ## [0.1.0] (2013-12-26) 306 | [0.1.0]: https://github.com/willnorris/imageproxy/compare/5d75e8a...v0.1.0 307 | 308 | Initial release. Supported transformation options include: 309 | - width and height 310 | - different crop modes 311 | - rotation (in 90 degree increments) 312 | - flip (horizontal or vertical) 313 | 314 | Images can be cached in-memory or on-disk. 315 | -------------------------------------------------------------------------------- /imageproxy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | // Package imageproxy provides an image proxy server. For typical use of 5 | // creating and using a Proxy, see cmd/imageproxy/main.go. 6 | package imageproxy // import "willnorris.com/go/imageproxy" 7 | 8 | import ( 9 | "bufio" 10 | "bytes" 11 | "crypto/hmac" 12 | "crypto/sha256" 13 | "encoding/base64" 14 | "errors" 15 | "fmt" 16 | "io" 17 | "io/ioutil" 18 | "log" 19 | "mime" 20 | "net" 21 | "net/http" 22 | "net/url" 23 | "path" 24 | "strings" 25 | "time" 26 | 27 | "github.com/gregjones/httpcache" 28 | "github.com/prometheus/client_golang/prometheus" 29 | "github.com/prometheus/client_golang/prometheus/promhttp" 30 | tphttp "willnorris.com/go/imageproxy/third_party/http" 31 | ) 32 | 33 | // Maximum number of redirection-followings allowed. 34 | const maxRedirects = 10 35 | 36 | // Proxy serves image requests. 37 | type Proxy struct { 38 | Client *http.Client // client used to fetch remote URLs 39 | Cache Cache // cache used to cache responses 40 | 41 | // AllowHosts specifies a list of remote hosts that images can be 42 | // proxied from. An empty list means all hosts are allowed. 43 | AllowHosts []string 44 | 45 | // DenyHosts specifies a list of remote hosts that images cannot be 46 | // proxied from. 47 | DenyHosts []string 48 | 49 | // Referrers, when given, requires that requests to the image 50 | // proxy come from a referring host. An empty list means all 51 | // hosts are allowed. 52 | Referrers []string 53 | 54 | // IncludeReferer controls whether the original Referer request header 55 | // is included in remote requests. 56 | IncludeReferer bool 57 | 58 | // FollowRedirects controls whether imageproxy will follow redirects or not. 59 | FollowRedirects bool 60 | 61 | // DefaultBaseURL is the URL that relative remote URLs are resolved in 62 | // reference to. If nil, all remote URLs specified in requests must be 63 | // absolute. 64 | DefaultBaseURL *url.URL 65 | 66 | // The Logger used by the image proxy 67 | Logger *log.Logger 68 | 69 | // SignatureKeys is a list of HMAC keys used to verify signed requests. 70 | // Any of them can be used to verify signed requests. 71 | SignatureKeys [][]byte 72 | 73 | // Allow images to scale beyond their original dimensions. 74 | ScaleUp bool 75 | 76 | // Timeout specifies a time limit for requests served by this Proxy. 77 | // If a call runs for longer than its time limit, a 504 Gateway Timeout 78 | // response is returned. A Timeout of zero means no timeout. 79 | Timeout time.Duration 80 | 81 | // If true, log additional debug messages 82 | Verbose bool 83 | 84 | // ContentTypes specifies a list of content types to allow. An empty 85 | // list means all content types are allowed. 86 | ContentTypes []string 87 | 88 | // The User-Agent used by imageproxy when requesting origin image 89 | UserAgent string 90 | } 91 | 92 | // NewProxy constructs a new proxy. The provided http RoundTripper will be 93 | // used to fetch remote URLs. If nil is provided, http.DefaultTransport will 94 | // be used. 95 | func NewProxy(transport http.RoundTripper, cache Cache) *Proxy { 96 | if transport == nil { 97 | transport = http.DefaultTransport 98 | } 99 | if cache == nil { 100 | cache = NopCache 101 | } 102 | 103 | proxy := &Proxy{ 104 | Cache: cache, 105 | } 106 | 107 | client := new(http.Client) 108 | client.Transport = &httpcache.Transport{ 109 | Transport: &TransformingTransport{ 110 | Transport: transport, 111 | CachingClient: client, 112 | log: func(format string, v ...interface{}) { 113 | if proxy.Verbose { 114 | proxy.logf(format, v...) 115 | } 116 | }, 117 | }, 118 | Cache: cache, 119 | MarkCachedResponses: true, 120 | } 121 | 122 | proxy.Client = client 123 | 124 | return proxy 125 | } 126 | 127 | // ServeHTTP handles incoming requests. 128 | func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 129 | if r.URL.Path == "/favicon.ico" { 130 | return // ignore favicon requests 131 | } 132 | 133 | if r.URL.Path == "/" || r.URL.Path == "/health-check" { 134 | fmt.Fprint(w, "OK") 135 | return 136 | } 137 | 138 | if r.URL.Path == "/metrics" { 139 | var h http.Handler = promhttp.Handler() 140 | h.ServeHTTP(w, r) 141 | return 142 | } 143 | 144 | var h http.Handler = http.HandlerFunc(p.serveImage) 145 | if p.Timeout > 0 { 146 | h = tphttp.TimeoutHandler(h, p.Timeout, "Gateway timeout waiting for remote resource.") 147 | } 148 | 149 | timer := prometheus.NewTimer(metricRequestDuration) 150 | metricRequestsInFlight.Inc() 151 | defer func() { 152 | timer.ObserveDuration() 153 | metricRequestsInFlight.Dec() 154 | }() 155 | 156 | h.ServeHTTP(w, r) 157 | } 158 | 159 | // serveImage handles incoming requests for proxied images. 160 | func (p *Proxy) serveImage(w http.ResponseWriter, r *http.Request) { 161 | req, err := NewRequest(r, p.DefaultBaseURL) 162 | if err != nil { 163 | p.log(fmt.Sprintf("%s: %v", msgInvalid, err)) 164 | http.Error(w, msgInvalid, http.StatusBadRequest) 165 | return 166 | } 167 | 168 | if err := p.allowed(req); err != nil { 169 | p.logf("%s: %v", err, req) 170 | http.Error(w, msgNotAllowed, http.StatusForbidden) 171 | return 172 | } 173 | 174 | // assign static settings from proxy to req.Options 175 | req.Options.ScaleUp = p.ScaleUp 176 | 177 | actualReq, _ := http.NewRequest("GET", req.String(), nil) 178 | actualReq.Header.Set("Accept", "*/*") 179 | actualReq.Header.Set("Accept-Language", "en-US,en;q=0.8") 180 | if p.UserAgent != "" { 181 | actualReq.Header.Set("User-Agent", p.UserAgent) 182 | } 183 | if p.IncludeReferer { 184 | // pass along the referer header from the original request 185 | copyHeader(actualReq.Header, r.Header, "referer") 186 | } 187 | if p.FollowRedirects { 188 | // FollowRedirects is true (default), ensure that the redirected host is allowed 189 | p.Client.CheckRedirect = func(newreq *http.Request, via []*http.Request) error { 190 | if len(via) > maxRedirects { 191 | if p.Verbose { 192 | p.logf("followed too many redirects (%d).", len(via)) 193 | } 194 | return errTooManyRedirects 195 | } 196 | if hostMatches(p.DenyHosts, newreq.URL) || (len(p.AllowHosts) > 0 && !hostMatches(p.AllowHosts, newreq.URL)) { 197 | http.Error(w, msgNotAllowedInRedirect, http.StatusForbidden) 198 | return errNotAllowed 199 | } 200 | return nil 201 | } 202 | } else { 203 | // FollowRedirects is false, don't follow redirects 204 | p.Client.CheckRedirect = func(newreq *http.Request, via []*http.Request) error { 205 | return http.ErrUseLastResponse 206 | } 207 | } 208 | resp, err := p.Client.Do(actualReq) 209 | 210 | if err != nil { 211 | msg := fmt.Sprintf("error fetching remote image: %v", err) 212 | p.log(msg) 213 | 214 | if v, ok := err.(*url.Error); ok && v.Err.Error() == "address matches a denied host" { 215 | http.Error(w, msgNotAllowed, http.StatusForbidden) 216 | } else { 217 | http.Error(w, msg, http.StatusInternalServerError) 218 | metricRemoteErrors.Inc() 219 | } 220 | return 221 | } 222 | // close the original resp.Body, even if we wrap it in a NopCloser below 223 | defer resp.Body.Close() 224 | 225 | cached := resp.Header.Get(httpcache.XFromCache) == "1" 226 | if p.Verbose { 227 | p.logf("request: %+v (served from cache: %t)", *actualReq, cached) 228 | } 229 | 230 | if cached { 231 | metricServedFromCache.Inc() 232 | } 233 | 234 | copyHeader(w.Header(), resp.Header, "Cache-Control", "Last-Modified", "Expires", "Etag", "Link") 235 | 236 | if should304(r, resp) { 237 | w.WriteHeader(http.StatusNotModified) 238 | return 239 | } 240 | 241 | contentType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) 242 | 243 | if isInvalidContentType(contentType) { 244 | // try to detect content type 245 | b := bufio.NewReader(resp.Body) 246 | resp.Body = ioutil.NopCloser(b) 247 | contentType = peekContentType(b) 248 | } 249 | if (resp.ContentLength != 0 && !contentTypeMatches(p.ContentTypes, contentType)) || strings.Contains(contentType, "svg") { 250 | p.logf("content-type not allowed: %q", contentType) 251 | http.Error(w, msgNotAllowed, http.StatusForbidden) 252 | return 253 | } 254 | w.Header().Set("Content-Type", contentType) 255 | 256 | copyHeader(w.Header(), resp.Header, "Content-Length") 257 | 258 | // Enable CORS for 3rd party applications 259 | w.Header().Set("Access-Control-Allow-Origin", "*") 260 | 261 | // Add a Content-Security-Policy to prevent stored-XSS attacks via SVG files 262 | w.Header().Set("Content-Security-Policy", "script-src 'none'") 263 | 264 | // Disable Content-Type sniffing 265 | w.Header().Set("X-Content-Type-Options", "nosniff") 266 | 267 | // Block potential XSS attacks especially in legacy browsers which do not support CSP 268 | w.Header().Set("X-XSS-Protection", "1; mode=block") 269 | 270 | w.WriteHeader(resp.StatusCode) 271 | if _, err := io.Copy(w, resp.Body); err != nil { 272 | p.logf("error copying response: %v", err) 273 | } 274 | } 275 | 276 | func isInvalidContentType(contentType string) bool { 277 | return contentType == "" || contentType == "application/octet-stream" || contentType == "application/x-directory" || contentType == "binary/octet-stream" || !strings.Contains(contentType, "/") 278 | } 279 | 280 | // peekContentType peeks at the first 512 bytes of p, and attempts to detect 281 | // the content type. Returns empty string if error occurs. 282 | func peekContentType(p *bufio.Reader) string { 283 | byt, err := p.Peek(512) 284 | if err != nil && err != bufio.ErrBufferFull && err != io.EOF { 285 | return "" 286 | } 287 | return http.DetectContentType(byt) 288 | } 289 | 290 | // copyHeader copies header values from src to dst, adding to any existing 291 | // values with the same header name. If keys is not empty, only those header 292 | // keys will be copied. 293 | func copyHeader(dst, src http.Header, keys ...string) { 294 | if len(keys) == 0 { 295 | for k := range src { 296 | keys = append(keys, k) 297 | } 298 | } 299 | for _, key := range keys { 300 | k := http.CanonicalHeaderKey(key) 301 | for _, v := range src[k] { 302 | dst.Add(k, v) 303 | } 304 | } 305 | } 306 | 307 | var ( 308 | errReferrer = errors.New("request does not contain an allowed referrer") 309 | errDeniedHost = errors.New("request contains a denied host") 310 | errNotAllowed = errors.New("request does not contain an allowed host or valid signature") 311 | errTooManyRedirects = errors.New("too many redirects") 312 | 313 | msgNotAllowed = "requested URL is not allowed" 314 | msgNotAllowedInRedirect = "requested URL in redirect is not allowed" 315 | msgInvalid = "requested URL is invalid" 316 | ) 317 | 318 | // allowed determines whether the specified request contains an allowed 319 | // referrer, host, and signature. It returns an error if the request is not 320 | // allowed. 321 | func (p *Proxy) allowed(r *Request) error { 322 | if len(p.Referrers) > 0 && !referrerMatches(p.Referrers, r.Original) { 323 | return errReferrer 324 | } 325 | 326 | if hostMatches(p.DenyHosts, r.URL) { 327 | return errDeniedHost 328 | } 329 | 330 | if len(p.AllowHosts) == 0 && len(p.SignatureKeys) == 0 { 331 | return nil // no allowed hosts or signature key, all requests accepted 332 | } 333 | 334 | if len(p.AllowHosts) > 0 && hostMatches(p.AllowHosts, r.URL) { 335 | return nil 336 | } 337 | 338 | for _, signatureKey := range p.SignatureKeys { 339 | if len(signatureKey) > 0 && validSignature(signatureKey, r) { 340 | return nil 341 | } 342 | } 343 | 344 | return errNotAllowed 345 | } 346 | 347 | // contentTypeMatches returns whether contentType matches one of the allowed patterns. 348 | func contentTypeMatches(patterns []string, contentType string) bool { 349 | if len(patterns) == 0 { 350 | return true 351 | } 352 | 353 | for _, pattern := range patterns { 354 | if ok, err := path.Match(pattern, contentType); ok && err == nil { 355 | return true 356 | } 357 | } 358 | 359 | return false 360 | } 361 | 362 | // hostMatches returns whether the host in u matches one of hosts. 363 | func hostMatches(hosts []string, u *url.URL) bool { 364 | for _, host := range hosts { 365 | if u.Hostname() == host { 366 | return true 367 | } 368 | if strings.HasPrefix(host, "*.") && strings.HasSuffix(u.Hostname(), host[2:]) { 369 | return true 370 | } 371 | 372 | // Checks whether the host in u is an IP 373 | if ip := net.ParseIP(u.Hostname()); ip != nil { 374 | // Checks whether our current host is a CIDR 375 | if _, ipnet, err := net.ParseCIDR(host); err == nil { 376 | // Checks if our host contains the IP in u 377 | if ipnet.Contains(ip) { 378 | return true 379 | } 380 | } 381 | } 382 | } 383 | 384 | return false 385 | } 386 | 387 | // referrerMatches returns whether the referrer from the request is in the host list. 388 | func referrerMatches(hosts []string, r *http.Request) bool { 389 | u, err := url.Parse(r.Header.Get("Referer")) 390 | if err != nil { // malformed or blank header, just deny 391 | return false 392 | } 393 | 394 | return hostMatches(hosts, u) 395 | } 396 | 397 | // validSignature returns whether the request signature is valid. 398 | func validSignature(key []byte, r *Request) bool { 399 | sig := r.Options.Signature 400 | if m := len(sig) % 4; m != 0 { // add padding if missing 401 | sig += strings.Repeat("=", 4-m) 402 | } 403 | 404 | got, err := base64.URLEncoding.DecodeString(sig) 405 | if err != nil { 406 | log.Printf("error base64 decoding signature %q", r.Options.Signature) 407 | return false 408 | } 409 | 410 | // check signature with URL only 411 | mac := hmac.New(sha256.New, key) 412 | _, _ = mac.Write([]byte(r.URL.String())) 413 | want := mac.Sum(nil) 414 | if hmac.Equal(got, want) { 415 | return true 416 | } 417 | 418 | // check signature with URL and options 419 | u, opt := *r.URL, r.Options // make copies 420 | opt.Signature = "" 421 | u.Fragment = opt.String() 422 | 423 | mac = hmac.New(sha256.New, key) 424 | _, _ = mac.Write([]byte(u.String())) 425 | want = mac.Sum(nil) 426 | return hmac.Equal(got, want) 427 | } 428 | 429 | // should304 returns whether we should send a 304 Not Modified in response to 430 | // req, based on the response resp. This is determined using the last modified 431 | // time and the entity tag of resp. 432 | func should304(req *http.Request, resp *http.Response) bool { 433 | // TODO(willnorris): if-none-match header can be a comma separated list 434 | // of multiple tags to be matched, or the special value "*" which 435 | // matches all etags 436 | etag := resp.Header.Get("Etag") 437 | if etag != "" && etag == req.Header.Get("If-None-Match") { 438 | return true 439 | } 440 | 441 | lastModified, err := time.Parse(time.RFC1123, resp.Header.Get("Last-Modified")) 442 | if err != nil { 443 | return false 444 | } 445 | ifModSince, err := time.Parse(time.RFC1123, req.Header.Get("If-Modified-Since")) 446 | if err != nil { 447 | return false 448 | } 449 | if lastModified.Before(ifModSince) || lastModified.Equal(ifModSince) { 450 | return true 451 | } 452 | 453 | return false 454 | } 455 | 456 | func (p *Proxy) log(v ...interface{}) { 457 | if p.Logger != nil { 458 | p.Logger.Print(v...) 459 | } else { 460 | log.Print(v...) 461 | } 462 | } 463 | 464 | func (p *Proxy) logf(format string, v ...interface{}) { 465 | if p.Logger != nil { 466 | p.Logger.Printf(format, v...) 467 | } else { 468 | log.Printf(format, v...) 469 | } 470 | } 471 | 472 | // TransformingTransport is an implementation of http.RoundTripper that 473 | // optionally transforms images using the options specified in the request URL 474 | // fragment. 475 | type TransformingTransport struct { 476 | // Transport is the underlying http.RoundTripper used to satisfy 477 | // non-transform requests (those that do not include a URL fragment). 478 | Transport http.RoundTripper 479 | 480 | // CachingClient is used to fetch images to be resized. This client is 481 | // used rather than Transport directly in order to ensure that 482 | // responses are properly cached. 483 | CachingClient *http.Client 484 | 485 | log func(format string, v ...interface{}) 486 | } 487 | 488 | // RoundTrip implements the http.RoundTripper interface. 489 | func (t *TransformingTransport) RoundTrip(req *http.Request) (*http.Response, error) { 490 | if req.URL.Fragment == "" { 491 | // normal requests pass through 492 | if t.log != nil { 493 | t.log("fetching remote URL: %v", req.URL) 494 | } 495 | return t.Transport.RoundTrip(req) 496 | } 497 | 498 | f := req.URL.Fragment 499 | req.URL.Fragment = "" 500 | resp, err := t.CachingClient.Do(req) 501 | req.URL.Fragment = f 502 | if err != nil { 503 | return nil, err 504 | } 505 | 506 | defer resp.Body.Close() 507 | 508 | if should304(req, resp) { 509 | // bare 304 response, full response will be used from cache 510 | return &http.Response{StatusCode: http.StatusNotModified}, nil 511 | } 512 | 513 | // Don't try to proxy anything > 128MB 514 | if resp.ContentLength > 134217728 { 515 | return nil, fmt.Errorf("content-length is too large: %v", resp.ContentLength) 516 | } 517 | 518 | b, err := ioutil.ReadAll(resp.Body) 519 | if err != nil { 520 | return nil, err 521 | } 522 | 523 | opt := ParseOptions(req.URL.Fragment) 524 | 525 | img, err := Transform(b, opt) 526 | if err != nil { 527 | log.Printf("error transforming image %s: %v", req.URL.String(), err) 528 | img = b 529 | } 530 | 531 | // replay response with transformed image and updated content length 532 | buf := new(bytes.Buffer) 533 | fmt.Fprintf(buf, "%s %s\n", resp.Proto, resp.Status) 534 | if err := resp.Header.WriteSubset(buf, map[string]bool{ 535 | "Content-Length": true, 536 | // exclude Content-Type header if the format may have changed during transformation 537 | "Content-Type": opt.Format != "" || resp.Header.Get("Content-Type") == "image/webp" || resp.Header.Get("Content-Type") == "image/tiff", 538 | }); err != nil { 539 | t.log("error copying headers: %v", err) 540 | } 541 | fmt.Fprintf(buf, "Content-Length: %d\n\n", len(img)) 542 | buf.Write(img) 543 | 544 | return http.ReadResponse(bufio.NewReader(buf), req) 545 | } 546 | -------------------------------------------------------------------------------- /imageproxy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "encoding/base64" 10 | "errors" 11 | "fmt" 12 | "image" 13 | "image/png" 14 | "log" 15 | "net/http" 16 | "net/http/httptest" 17 | "net/url" 18 | "os" 19 | "reflect" 20 | "regexp" 21 | "strconv" 22 | "strings" 23 | "testing" 24 | ) 25 | 26 | func TestPeekContentType(t *testing.T) { 27 | // 1 pixel png image, base64 encoded 28 | b, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAEUlEQVR4nGJiYGBgAAQAAP//AA8AA/6P688AAAAASUVORK5CYII=") 29 | got := peekContentType(bufio.NewReader(bytes.NewReader(b))) 30 | if want := "image/png"; got != want { 31 | t.Errorf("peekContentType returned %v, want %v", got, want) 32 | } 33 | 34 | // single zero byte 35 | got = peekContentType(bufio.NewReader(bytes.NewReader([]byte{0x0}))) 36 | if want := "application/octet-stream"; got != want { 37 | t.Errorf("peekContentType returned %v, want %v", got, want) 38 | } 39 | } 40 | 41 | func TestCopyHeader(t *testing.T) { 42 | tests := []struct { 43 | dst, src http.Header 44 | keys []string 45 | want http.Header 46 | }{ 47 | // empty 48 | {http.Header{}, http.Header{}, nil, http.Header{}}, 49 | {http.Header{}, http.Header{}, []string{}, http.Header{}}, 50 | {http.Header{}, http.Header{}, []string{"A"}, http.Header{}}, 51 | 52 | // nothing to copy 53 | { 54 | dst: http.Header{"A": []string{"a1"}}, 55 | src: http.Header{}, 56 | keys: nil, 57 | want: http.Header{"A": []string{"a1"}}, 58 | }, 59 | { 60 | dst: http.Header{}, 61 | src: http.Header{"A": []string{"a"}}, 62 | keys: []string{"B"}, 63 | want: http.Header{}, 64 | }, 65 | 66 | // copy headers 67 | { 68 | dst: http.Header{}, 69 | src: http.Header{"A": []string{"a"}}, 70 | keys: nil, 71 | want: http.Header{"A": []string{"a"}}, 72 | }, 73 | { 74 | dst: http.Header{"A": []string{"a"}}, 75 | src: http.Header{"B": []string{"b"}}, 76 | keys: nil, 77 | want: http.Header{"A": []string{"a"}, "B": []string{"b"}}, 78 | }, 79 | { 80 | dst: http.Header{"A": []string{"a"}}, 81 | src: http.Header{"B": []string{"b"}, "C": []string{"c"}}, 82 | keys: []string{"B"}, 83 | want: http.Header{"A": []string{"a"}, "B": []string{"b"}}, 84 | }, 85 | { 86 | dst: http.Header{"A": []string{"a1"}}, 87 | src: http.Header{"A": []string{"a2"}}, 88 | keys: nil, 89 | want: http.Header{"A": []string{"a1", "a2"}}, 90 | }, 91 | } 92 | 93 | for _, tt := range tests { 94 | // copy dst map 95 | got := make(http.Header) 96 | for k, v := range tt.dst { 97 | got[k] = v 98 | } 99 | 100 | copyHeader(got, tt.src, tt.keys...) 101 | if !reflect.DeepEqual(got, tt.want) { 102 | t.Errorf("copyHeader(%v, %v, %v) returned %v, want %v", tt.dst, tt.src, tt.keys, got, tt.want) 103 | } 104 | } 105 | } 106 | 107 | func TestAllowed(t *testing.T) { 108 | allowHosts := []string{"good"} 109 | key := [][]byte{ 110 | []byte("c0ffee"), 111 | } 112 | multipleKey := [][]byte{ 113 | []byte("c0ffee"), 114 | []byte("beer"), 115 | } 116 | 117 | genRequest := func(headers map[string]string) *http.Request { 118 | req := &http.Request{Header: make(http.Header)} 119 | for key, value := range headers { 120 | req.Header.Set(key, value) 121 | } 122 | return req 123 | } 124 | 125 | tests := []struct { 126 | url string 127 | options Options 128 | allowHosts []string 129 | denyHosts []string 130 | referrers []string 131 | keys [][]byte 132 | request *http.Request 133 | allowed bool 134 | }{ 135 | // no allowHosts or signature key 136 | {"http://test/image", emptyOptions, nil, nil, nil, nil, nil, true}, 137 | 138 | // allowHosts 139 | {"http://good/image", emptyOptions, allowHosts, nil, nil, nil, nil, true}, 140 | {"http://bad/image", emptyOptions, allowHosts, nil, nil, nil, nil, false}, 141 | 142 | // referrer 143 | {"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{"Referer": "http://good/foo"}), true}, 144 | {"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{"Referer": "http://bad/foo"}), false}, 145 | {"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{"Referer": "MALFORMED!!"}), false}, 146 | {"http://test/image", emptyOptions, nil, nil, allowHosts, nil, genRequest(map[string]string{}), false}, 147 | 148 | // signature key 149 | {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, nil, nil, key, nil, true}, 150 | {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, nil, nil, multipleKey, nil, true}, // signed with key "c0ffee" 151 | {"http://test/image", Options{Signature: "FWIawYV4SEyI4zKJMeGugM-eJM1eI_jXPEQ20ZgRe4A="}, nil, nil, nil, multipleKey, nil, true}, // signed with key "beer" 152 | {"http://test/image", Options{Signature: "deadbeef"}, nil, nil, nil, key, nil, false}, 153 | {"http://test/image", Options{Signature: "deadbeef"}, nil, nil, nil, multipleKey, nil, false}, 154 | {"http://test/image", emptyOptions, nil, nil, nil, key, nil, false}, 155 | 156 | // allowHosts and signature 157 | {"http://good/image", emptyOptions, allowHosts, nil, nil, key, nil, true}, 158 | {"http://bad/image", Options{Signature: "gWivrPhXBbsYEwpmWAKjbJEiAEgZwbXbltg95O2tgNI="}, nil, nil, nil, key, nil, true}, 159 | {"http://bad/image", emptyOptions, allowHosts, nil, nil, key, nil, false}, 160 | 161 | // deny requests that match denyHosts, even if signature is valid or also matches allowHosts 162 | {"http://test/image", emptyOptions, nil, []string{"test"}, nil, nil, nil, false}, 163 | {"http://test:3000/image", emptyOptions, nil, []string{"test"}, nil, nil, nil, false}, 164 | {"http://test/image", emptyOptions, []string{"test"}, []string{"test"}, nil, nil, nil, false}, 165 | {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, nil, []string{"test"}, nil, key, nil, false}, 166 | {"http://127.0.0.1/image", emptyOptions, nil, []string{"127.0.0.0/8"}, nil, nil, nil, false}, 167 | {"http://127.0.0.1:3000/image", emptyOptions, nil, []string{"127.0.0.0/8"}, nil, nil, nil, false}, 168 | } 169 | 170 | for _, tt := range tests { 171 | p := NewProxy(nil, nil) 172 | p.AllowHosts = tt.allowHosts 173 | p.DenyHosts = tt.denyHosts 174 | p.SignatureKeys = tt.keys 175 | p.Referrers = tt.referrers 176 | 177 | u, err := url.Parse(tt.url) 178 | if err != nil { 179 | t.Errorf("error parsing url %q: %v", tt.url, err) 180 | } 181 | req := &Request{u, tt.options, tt.request} 182 | if got, want := p.allowed(req), tt.allowed; (got == nil) != want { 183 | t.Errorf("allowed(%q) returned %v, want %v.\nTest struct: %#v", req, got, want, tt) 184 | } 185 | } 186 | } 187 | 188 | func TestHostMatches(t *testing.T) { 189 | hosts := []string{"a.test", "*.b.test", "*c.test"} 190 | 191 | tests := []struct { 192 | url string 193 | valid bool 194 | }{ 195 | {"http://a.test/image", true}, 196 | {"http://x.a.test/image", false}, 197 | 198 | {"http://b.test/image", true}, 199 | {"http://x.b.test/image", true}, 200 | {"http://x.y.b.test/image", true}, 201 | 202 | {"http://c.test/image", false}, 203 | {"http://xc.test/image", false}, 204 | {"/image", false}, 205 | } 206 | 207 | for _, tt := range tests { 208 | u, err := url.Parse(tt.url) 209 | if err != nil { 210 | t.Errorf("error parsing url %q: %v", tt.url, err) 211 | } 212 | if got, want := hostMatches(hosts, u), tt.valid; got != want { 213 | t.Errorf("hostMatches(%v, %q) returned %v, want %v", hosts, u, got, want) 214 | } 215 | } 216 | } 217 | 218 | func TestReferrerMatches(t *testing.T) { 219 | hosts := []string{"a.test"} 220 | 221 | tests := []struct { 222 | referrer string 223 | valid bool 224 | }{ 225 | {"", false}, 226 | {"%", false}, 227 | {"http://a.test/", true}, 228 | {"http://b.test/", false}, 229 | } 230 | 231 | for _, tt := range tests { 232 | r, _ := http.NewRequest("GET", "/", nil) 233 | r.Header.Set("Referer", tt.referrer) 234 | if got, want := referrerMatches(hosts, r), tt.valid; got != want { 235 | t.Errorf("referrerMatches(%v, %v) returned %v, want %v", hosts, r, got, want) 236 | } 237 | } 238 | } 239 | 240 | func TestValidSignature(t *testing.T) { 241 | key := []byte("c0ffee") 242 | 243 | tests := []struct { 244 | url string 245 | options Options 246 | valid bool 247 | }{ 248 | {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ="}, true}, 249 | {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ"}, true}, 250 | {"http://test/image", emptyOptions, false}, 251 | // url-only signature with options 252 | {"http://test/image", Options{Signature: "NDx5zZHx7QfE8E-ijowRreq6CJJBZjwiRfOVk_mkfQQ", Rotate: 90}, true}, 253 | // signature calculated from url plus options 254 | {"http://test/image", Options{Signature: "ZGTzEm32o4iZ7qcChls3EVYaWyrDd9u0etySo0-WkF8=", Rotate: 90}, true}, 255 | // invalid base64 encoded signature 256 | {"http://test/image", Options{Signature: "!!"}, false}, 257 | } 258 | 259 | for _, tt := range tests { 260 | u, err := url.Parse(tt.url) 261 | if err != nil { 262 | t.Errorf("error parsing url %q: %v", tt.url, err) 263 | } 264 | req := &Request{u, tt.options, &http.Request{}} 265 | if got, want := validSignature(key, req), tt.valid; got != want { 266 | t.Errorf("validSignature(%v, %v) returned %v, want %v", key, req, got, want) 267 | } 268 | } 269 | } 270 | 271 | func TestShould304(t *testing.T) { 272 | tests := []struct { 273 | req, resp string 274 | is304 bool 275 | }{ 276 | { // etag match 277 | "GET / HTTP/1.1\nIf-None-Match: \"v\"\n\n", 278 | "HTTP/1.1 200 OK\nEtag: \"v\"\n\n", 279 | true, 280 | }, 281 | { // last-modified before 282 | "GET / HTTP/1.1\nIf-Modified-Since: Sun, 02 Jan 2000 00:00:00 GMT\n\n", 283 | "HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n", 284 | true, 285 | }, 286 | { // last-modified match 287 | "GET / HTTP/1.1\nIf-Modified-Since: Sat, 01 Jan 2000 00:00:00 GMT\n\n", 288 | "HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n", 289 | true, 290 | }, 291 | 292 | // mismatches 293 | { 294 | "GET / HTTP/1.1\n\n", 295 | "HTTP/1.1 200 OK\n\n", 296 | false, 297 | }, 298 | { 299 | "GET / HTTP/1.1\n\n", 300 | "HTTP/1.1 200 OK\nEtag: \"v\"\n\n", 301 | false, 302 | }, 303 | { 304 | "GET / HTTP/1.1\nIf-None-Match: \"v\"\n\n", 305 | "HTTP/1.1 200 OK\n\n", 306 | false, 307 | }, 308 | { 309 | "GET / HTTP/1.1\nIf-None-Match: \"a\"\n\n", 310 | "HTTP/1.1 200 OK\nEtag: \"b\"\n\n", 311 | false, 312 | }, 313 | { // last-modified match 314 | "GET / HTTP/1.1\n\n", 315 | "HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n", 316 | false, 317 | }, 318 | { // last-modified match 319 | "GET / HTTP/1.1\nIf-Modified-Since: Sun, 02 Jan 2000 00:00:00 GMT\n\n", 320 | "HTTP/1.1 200 OK\n\n", 321 | false, 322 | }, 323 | { // last-modified match 324 | "GET / HTTP/1.1\nIf-Modified-Since: Fri, 31 Dec 1999 00:00:00 GMT\n\n", 325 | "HTTP/1.1 200 OK\nLast-Modified: Sat, 01 Jan 2000 00:00:00 GMT\n\n", 326 | false, 327 | }, 328 | } 329 | 330 | for _, tt := range tests { 331 | buf := bufio.NewReader(strings.NewReader(tt.req)) 332 | req, err := http.ReadRequest(buf) 333 | if err != nil { 334 | t.Errorf("http.ReadRequest(%q) returned error: %v", tt.req, err) 335 | } 336 | 337 | buf = bufio.NewReader(strings.NewReader(tt.resp)) 338 | resp, err := http.ReadResponse(buf, req) 339 | if err != nil { 340 | t.Errorf("http.ReadResponse(%q) returned error: %v", tt.resp, err) 341 | } 342 | 343 | if got, want := should304(req, resp), tt.is304; got != want { 344 | t.Errorf("should304(%q, %q) returned: %v, want %v", tt.req, tt.resp, got, want) 345 | } 346 | } 347 | } 348 | 349 | // testTransport is an http.RoundTripper that returns certained canned 350 | // responses for particular requests. 351 | type testTransport struct{} 352 | 353 | func (t testTransport) RoundTrip(req *http.Request) (*http.Response, error) { 354 | var raw string 355 | 356 | switch req.URL.Path { 357 | case "/plain": 358 | raw = "HTTP/1.1 200 OK\n\n" 359 | case "/error": 360 | return nil, errors.New("http protocol error") 361 | case "/denied": 362 | return nil, errors.New("address matches a denied host") 363 | case "/nocontent": 364 | raw = "HTTP/1.1 204 No Content\n\n" 365 | case "/etag": 366 | raw = "HTTP/1.1 200 OK\nEtag: \"tag\"\n\n" 367 | case "/png": 368 | m := image.NewNRGBA(image.Rect(0, 0, 1, 1)) 369 | img := new(bytes.Buffer) 370 | _ = png.Encode(img, m) 371 | 372 | raw = fmt.Sprintf("HTTP/1.1 200 OK\nContent-Length: %d\nContent-Type: image/png\n\n%s", len(img.Bytes()), img.Bytes()) 373 | default: 374 | redirectRegexp := regexp.MustCompile(`/redirects-(\d+)`) 375 | if redirectRegexp.MatchString(req.URL.Path) { 376 | redirectsLeft, _ := strconv.ParseUint(redirectRegexp.FindStringSubmatch(req.URL.Path)[1], 10, 8) 377 | if redirectsLeft == 0 { 378 | raw = "HTTP/1.1 200 OK\n\n" 379 | } else { 380 | raw = fmt.Sprintf("HTTP/1.1 302\nLocation: /http://redirect.test/redirects-%d\n\n", redirectsLeft-1) 381 | } 382 | } else { 383 | raw = "HTTP/1.1 404 Not Found\n\n" 384 | } 385 | } 386 | 387 | buf := bufio.NewReader(bytes.NewBufferString(raw)) 388 | return http.ReadResponse(buf, req) 389 | } 390 | 391 | func TestProxy_ServeHTTP(t *testing.T) { 392 | p := &Proxy{ 393 | Client: &http.Client{ 394 | Transport: testTransport{}, 395 | }, 396 | DenyHosts: []string{"bad.test"}, 397 | ContentTypes: []string{"image/*"}, 398 | } 399 | 400 | tests := []struct { 401 | url string // request URL 402 | code int // expected response status code 403 | }{ 404 | {"/favicon.ico", http.StatusOK}, 405 | {"//foo", http.StatusBadRequest}, // invalid request URL 406 | {"/http://bad.test/", http.StatusForbidden}, // Disallowed host 407 | {"/http://local.test/denied", http.StatusForbidden}, // Denied host after resolving 408 | {"/http://good.test/error", http.StatusInternalServerError}, // HTTP protocol error 409 | {"/http://good.test/nocontent", http.StatusNoContent}, // non-OK response 410 | {"/100/http://good.test/png", http.StatusOK}, 411 | {"/100/http://good.test/plain", http.StatusForbidden}, // non-image response 412 | 413 | // health-check URLs 414 | {"/", http.StatusOK}, 415 | {"/health-check", http.StatusOK}, 416 | } 417 | 418 | for _, tt := range tests { 419 | req, _ := http.NewRequest("GET", "http://localhost"+tt.url, nil) 420 | resp := httptest.NewRecorder() 421 | p.ServeHTTP(resp, req) 422 | 423 | if got, want := resp.Code, tt.code; got != want { 424 | t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want) 425 | } 426 | } 427 | } 428 | 429 | // test that 304 Not Modified responses are returned properly. 430 | func TestProxy_ServeHTTP_is304(t *testing.T) { 431 | p := &Proxy{ 432 | Client: &http.Client{ 433 | Transport: testTransport{}, 434 | }, 435 | } 436 | 437 | req, _ := http.NewRequest("GET", "http://localhost/http://good.test/etag", nil) 438 | req.Header.Add("If-None-Match", `"tag"`) 439 | resp := httptest.NewRecorder() 440 | p.ServeHTTP(resp, req) 441 | 442 | if got, want := resp.Code, http.StatusNotModified; got != want { 443 | t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want) 444 | } 445 | if got, want := resp.Header().Get("Etag"), `"tag"`; got != want { 446 | t.Errorf("ServeHTTP(%v) returned etag header %v, want %v", req, got, want) 447 | } 448 | } 449 | 450 | func TestProxy_ServeHTTP_maxRedirects(t *testing.T) { 451 | p := &Proxy{ 452 | Client: &http.Client{ 453 | Transport: testTransport{}, 454 | }, 455 | FollowRedirects: true, 456 | } 457 | 458 | tests := []struct { 459 | url string 460 | code int 461 | }{ 462 | {"/http://redirect.test/redirects-0", http.StatusOK}, 463 | {"/http://redirect.test/redirects-2", http.StatusOK}, 464 | {"/http://redirect.test/redirects-11", http.StatusInternalServerError}, // too many redirects 465 | } 466 | 467 | for _, tt := range tests { 468 | req, _ := http.NewRequest("GET", "http://localhost"+tt.url, nil) 469 | resp := httptest.NewRecorder() 470 | p.ServeHTTP(resp, req) 471 | 472 | if got, want := resp.Code, tt.code; got != want { 473 | t.Errorf("ServeHTTP(%v) returned status %d, want %d", req, got, want) 474 | } 475 | } 476 | } 477 | 478 | func TestProxy_log(t *testing.T) { 479 | var b strings.Builder 480 | 481 | p := &Proxy{ 482 | Logger: log.New(&b, "", 0), 483 | } 484 | p.log("Test") 485 | 486 | if got, want := b.String(), "Test\n"; got != want { 487 | t.Errorf("log wrote %s, want %s", got, want) 488 | } 489 | 490 | b.Reset() 491 | p.logf("Test %v", 123) 492 | 493 | if got, want := b.String(), "Test 123\n"; got != want { 494 | t.Errorf("logf wrote %s, want %s", got, want) 495 | } 496 | } 497 | 498 | func TestProxy_log_default(t *testing.T) { 499 | var b strings.Builder 500 | 501 | defer func(flags int) { 502 | log.SetOutput(os.Stderr) 503 | log.SetFlags(flags) 504 | }(log.Flags()) 505 | 506 | log.SetOutput(&b) 507 | log.SetFlags(0) 508 | 509 | p := &Proxy{} 510 | p.log("Test") 511 | 512 | if got, want := b.String(), "Test\n"; got != want { 513 | t.Errorf("log wrote %s, want %s", got, want) 514 | } 515 | 516 | b.Reset() 517 | p.logf("Test %v", 123) 518 | 519 | if got, want := b.String(), "Test 123\n"; got != want { 520 | t.Errorf("logf wrote %s, want %s", got, want) 521 | } 522 | } 523 | 524 | func TestTransformingTransport(t *testing.T) { 525 | client := new(http.Client) 526 | tr := &TransformingTransport{ 527 | Transport: testTransport{}, 528 | CachingClient: client, 529 | } 530 | client.Transport = tr 531 | 532 | tests := []struct { 533 | url string 534 | code int 535 | expectError bool 536 | }{ 537 | {"http://good.test/png#1", http.StatusOK, false}, 538 | {"http://good.test/error#1", http.StatusInternalServerError, true}, 539 | // TODO: test more than just status code... verify that image 540 | // is actually transformed and returned properly and that 541 | // non-image responses are returned as-is 542 | } 543 | 544 | for _, tt := range tests { 545 | req, _ := http.NewRequest("GET", tt.url, nil) 546 | 547 | resp, err := tr.RoundTrip(req) 548 | if err != nil { 549 | if !tt.expectError { 550 | t.Errorf("RoundTrip(%v) returned unexpected error: %v", tt.url, err) 551 | } 552 | continue 553 | } else if tt.expectError { 554 | t.Errorf("RoundTrip(%v) did not return expected error", tt.url) 555 | } 556 | if got, want := resp.StatusCode, tt.code; got != want { 557 | t.Errorf("RoundTrip(%v) returned status code %d, want %d", tt.url, got, want) 558 | } 559 | } 560 | } 561 | 562 | func TestContentTypeMatches(t *testing.T) { 563 | tests := []struct { 564 | patterns []string 565 | contentType string 566 | valid bool 567 | }{ 568 | // no patterns 569 | {nil, "", true}, 570 | {nil, "text/plain", true}, 571 | {[]string{}, "", true}, 572 | {[]string{}, "text/plain", true}, 573 | 574 | // empty pattern 575 | {[]string{""}, "", true}, 576 | {[]string{""}, "text/plain", false}, 577 | 578 | // exact match 579 | {[]string{"text/plain"}, "", false}, 580 | {[]string{"text/plain"}, "text", false}, 581 | {[]string{"text/plain"}, "text/html", false}, 582 | {[]string{"text/plain"}, "text/plain", true}, 583 | {[]string{"text/plain"}, "text/plaintext", false}, 584 | {[]string{"text/plain"}, "text/plain+foo", false}, 585 | 586 | // wildcard match 587 | {[]string{"text/*"}, "", false}, 588 | {[]string{"text/*"}, "text", false}, 589 | {[]string{"text/*"}, "text/html", true}, 590 | {[]string{"text/*"}, "text/plain", true}, 591 | {[]string{"text/*"}, "image/jpeg", false}, 592 | 593 | {[]string{"image/svg*"}, "image/svg", true}, 594 | {[]string{"image/svg*"}, "image/svg+html", true}, 595 | 596 | // complete wildcard does not match 597 | {[]string{"*"}, "text/foobar", false}, 598 | 599 | // multiple patterns 600 | {[]string{"text/*", "image/*"}, "image/jpeg", true}, 601 | } 602 | for _, tt := range tests { 603 | got := contentTypeMatches(tt.patterns, tt.contentType) 604 | if want := tt.valid; got != want { 605 | t.Errorf("contentTypeMatches(%q, %q) returned %v, want %v", tt.patterns, tt.contentType, got, want) 606 | } 607 | } 608 | } 609 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imageproxy 2 | 3 | **Warning** This is a Basecamp fork for HEY with specific customizations that may not fit in with the upstream project. 4 | 5 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue)](https://pkg.go.dev/willnorris.com/go/imageproxy) 6 | [![Test Status](https://github.com/willnorris/imageproxy/workflows/tests/badge.svg)](https://github.com/willnorris/imageproxy/actions?query=workflow%3Atests) 7 | [![Test Coverage](https://codecov.io/gh/willnorris/imageproxy/branch/main/graph/badge.svg)](https://codecov.io/gh/willnorris/imageproxy) 8 | [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/2611/badge)](https://bestpractices.coreinfrastructure.org/projects/2611) 9 | 10 | imageproxy is a caching image proxy server written in go. It features: 11 | 12 | - basic image adjustments like resizing, cropping, and rotation 13 | - access control using allowed hosts list or request signing (HMAC-SHA256) 14 | - support for jpeg, png, webp (decode only), tiff, and gif image formats 15 | (including animated gifs) 16 | - caching in-memory, on disk, or with Amazon S3, Google Cloud Storage, Azure 17 | Storage, or Redis 18 | - easy deployment, since it's pure go 19 | 20 | Personally, I use it primarily to dynamically resize images hosted on my own 21 | site (read more in [this post][]). But you can also enable request signing and 22 | use it as an SSL proxy for remote images, similar to [atmos/camo][] but with 23 | additional image adjustment options. 24 | 25 | I aim to keep imageproxy compatible with the two [most recent major go 26 | releases][]. I also keep track of the minimum go version that still works 27 | (currently go1.11 with modules enabled), but that might change at any time. You 28 | can see the go versions that are tested against in 29 | [.github/workflows/tests.yml][]. 30 | 31 | [this post]: https://willnorris.com/2014/01/a-self-hosted-alternative-to-jetpacks-photon-service 32 | [atmos/camo]: https://github.com/atmos/camo 33 | [most recent major go releases]: https://golang.org/doc/devel/release.html 34 | [.github/workflows/tests.yml]: ./.github/workflows/tests.yml 35 | 36 | ## URL Structure ## 37 | 38 | imageproxy URLs are of the form `http://localhost/{options}/{remote_url}`. 39 | 40 | ### Options ### 41 | 42 | Options are available for cropping, resizing, rotation, flipping, and digital 43 | signatures among a few others. Options for are specified as a comma delimited 44 | list of parameters, which can be supplied in any order. Duplicate parameters 45 | overwrite previous values. 46 | 47 | See the full list of available options at 48 | . 49 | 50 | ### Remote URL ### 51 | 52 | The URL of the original image to load is specified as the remainder of the 53 | path, without any encoding. For example, 54 | `http://localhost/200/https://willnorris.com/logo.jpg`. 55 | 56 | In order to [optimize caching][], it is recommended that URLs not contain query 57 | strings. 58 | 59 | [optimize caching]: http://www.stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring/ 60 | 61 | ### Examples ### 62 | 63 | The following live examples demonstrate setting different options on [this 64 | source image][small-things], which measures 1024 by 678 pixels. 65 | 66 | [small-things]: https://willnorris.com/2013/12/small-things.jpg 67 | 68 | Options | Meaning | Image 69 | --------|------------------------------------------|------ 70 | 200x | 200px wide, proportional height | 200x 71 | x0.15 | 15% original height, proportional width | x0.15 72 | 100x150 | 100 by 150 pixels, cropping as needed | 100x150 73 | 100 | 100px square, cropping as needed | 100 74 | 150,fit | scale to fit 150px square, no cropping | 150,fit 75 | 100,r90 | 100px square, rotated 90 degrees | 100,r90 76 | 100,fv,fh | 100px square, flipped horizontal and vertical | 100,fv,fh 77 | 200x,q60 | 200px wide, proportional height, 60% quality | 200x,q60 78 | 200x,png | 200px wide, converted to PNG format | 200x,png 79 | cx175,cw400,ch300,100x | crop to 400x300px starting at (175,0), scale to 100px wide | cx175,cw400,ch300,100x 80 | 81 | The [smart crop feature](https://godoc.org/willnorris.com/go/imageproxy#hdr-Smart_Crop) 82 | can best be seen by comparing crops of [this source image][judah-sheets], with 83 | and without smart crop enabled. 84 | 85 | Options | Meaning | Image 86 | --------|------------------------------------------|------ 87 | 150x300 | 150x300px, standard crop | 200x400,sc 88 | 150x300,sc | 150x300px, smart crop | 200x400 89 | 90 | [judah-sheets]: https://judahnorris.com/images/judah-sheets.jpg 91 | 92 | Transformation also works on animated gifs. Here is [this source 93 | image][material-animation] resized to 200px square and rotated 270 degrees: 94 | 95 | [material-animation]: https://willnorris.com/2015/05/material-animations.gif 96 | 97 | 200,r270 98 | 99 | ## Getting Started ## 100 | 101 | Install the package using: 102 | 103 | go get willnorris.com/go/imageproxy/cmd/imageproxy 104 | 105 | Once installed, ensure `$GOPATH/bin` is in your `$PATH`, then run the proxy 106 | using: 107 | 108 | imageproxy 109 | 110 | This will start the proxy on port 8080, without any caching and with no allowed 111 | host list (meaning any remote URL can be proxied). Test this by navigating to 112 | and 113 | you should see a 500px square coder octocat. 114 | 115 | ### Cache ### 116 | 117 | By default, the imageproxy command does not cache responses, but caching can be 118 | enabled using the `-cache` flag. It supports the following values: 119 | 120 | - `memory` - uses an in-memory LRU cache. By default, this is limited to 121 | 100mb. To customize the size of the cache or the max age for cached items, 122 | use the format `memory:size:age` where size is measured in mb and age is a 123 | duration. For example, `memory:200:4h` will create a 200mb cache that will 124 | cache items no longer than 4 hours. 125 | - directory on local disk (e.g. `/tmp/imageproxy`) - will cache images 126 | on disk 127 | 128 | - s3 URL (e.g. `s3://region/bucket-name/optional-path-prefix`) - will cache 129 | images on Amazon S3. This requires either an IAM role and instance profile 130 | with access to your your bucket or `AWS_ACCESS_KEY_ID` and `AWS_SECRET_KEY` 131 | environmental variables be set. (Additional methods of loading credentials 132 | are documented in the [aws-sdk-go session 133 | package](https://docs.aws.amazon.com/sdk-for-go/api/aws/session/)). 134 | 135 | Additional configuration options ([further documented here][aws-options]) 136 | may be specified as URL query string parameters, which are mostly useful 137 | when working with s3-compatible services: 138 | - "endpoint" - specify an alternate API endpoint 139 | - "disableSSL" - set to "1" to disable SSL when calling the API 140 | - "s3ForcePathStyle" - set to "1" to force the request to use path-style addressing 141 | 142 | For example, when working with [minio](https://minio.io), which doesn't use 143 | regions, provide a dummy region value and custom endpoint value: 144 | 145 | s3://fake-region/bucket/folder?endpoint=minio:9000&disableSSL=1&s3ForcePathStyle=1 146 | 147 | Similarly, for [Digital Ocean Spaces](https://www.digitalocean.com/products/spaces/), 148 | provide a dummy region value and the appropriate endpoint for your space: 149 | 150 | s3://fake-region/bucket/folder?endpoint=sfo2.digitaloceanspaces.com 151 | 152 | [aws-options]: https://docs.aws.amazon.com/sdk-for-go/api/aws/#Config 153 | 154 | - gcs URL (e.g. `gcs://bucket-name/optional-path-prefix`) - will cache images 155 | on Google Cloud Storage. Authentication is documented in Google's 156 | [Application Default Credentials 157 | docs](https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application). 158 | - azure URL (e.g. `azure://container-name/`) - will cache images on 159 | Azure Storage. This requires `AZURESTORAGE_ACCOUNT_NAME` and 160 | `AZURESTORAGE_ACCESS_KEY` environment variables to bet set. 161 | - redis URL (e.g. `redis://hostname/`) - will cache images on 162 | the specified redis host. The full URL syntax is defined by the [redis URI 163 | registration](https://www.iana.org/assignments/uri-schemes/prov/redis). 164 | Rather than specify password in the URI, use the `REDIS_PASSWORD` 165 | environment variable. 166 | 167 | For example, to cache files on disk in the `/tmp/imageproxy` directory: 168 | 169 | imageproxy -cache /tmp/imageproxy 170 | 171 | Reload the [codercat URL][], and then inspect the contents of 172 | `/tmp/imageproxy`. Within the subdirectories, there should be two files, one 173 | for the original full-size codercat image, and one for the resized 500px 174 | version. 175 | 176 | [codercat URL]: http://localhost:8080/500/https://octodex.github.com/images/codercat.jpg 177 | 178 | Multiple caches can be specified by separating them by spaces or by repeating 179 | the `-cache` flag multiple times. The caches will be created in a [tiered 180 | fashion][]. Typically this is used to put a smaller and faster in-memory cache 181 | in front of a larger but slower on-disk cache. For example, the following will 182 | first check an in-memory cache for an image, followed by a gcs bucket: 183 | 184 | imageproxy -cache memory -cache gcs://my-bucket/ 185 | 186 | [tiered fashion]: https://godoc.org/github.com/die-net/lrucache/twotier 187 | 188 | ### Allowed Referrer List ### 189 | 190 | You can limit images to only be accessible for certain hosts in the HTTP 191 | referrer header, which can help prevent others from hotlinking to images. It can 192 | be enabled by running: 193 | 194 | imageproxy -referrers example.com 195 | 196 | 197 | Reload the [codercat URL][], and you should now get an error message. You can 198 | specify multiple hosts as a comma separated list, or prefix a host value with 199 | `*.` to allow all sub-domains as well. 200 | 201 | ### Allowed and Denied Hosts List ### 202 | 203 | You can limit the remote hosts that the proxy will fetch images from using the 204 | `allowHosts` and `denyHosts` flags. This is useful, for example, for locking 205 | the proxy down to your own hosts to prevent others from abusing it. Of course 206 | if you want to support fetching from any host, leave off these flags. 207 | 208 | Try it out by running: 209 | 210 | imageproxy -allowHosts example.com 211 | 212 | Reload the [codercat URL][], and you should now get an error message. 213 | Alternately, try running: 214 | 215 | imageproxy -denyHosts octodex.github.com 216 | 217 | Reloading the [codercat URL][] will still return an error message. 218 | 219 | You can specify multiple hosts as a comma separated list to either flag, or 220 | prefix a host value with `*.` to allow or deny all sub-domains. You can 221 | also specify a netblock in CIDR notation (`127.0.0.0/8`) -- this is useful for 222 | blocking reserved ranges like `127.0.0.0/8`, `192.168.0.0/16`, etc. 223 | 224 | If a host matches both an allowed and denied host, the request will be denied. 225 | 226 | ### Allowed Content-Type List ### 227 | 228 | You can limit what content types can be proxied by using the `contentTypes` 229 | flag. By default, this is set to `image/*`, meaning that imageproxy will 230 | process any image types. You can specify multiple content types as a comma 231 | separated list, and suffix values with `*` to perform a wildcard match. Set the 232 | flag to an empty string to proxy all requests, regardless of content type. 233 | 234 | ### Signed Requests ### 235 | 236 | Instead of an allowed host list, you can require that requests be signed. This 237 | is useful in preventing abuse when you don't have just a static list of hosts 238 | you want to allow. Signatures are generated using HMAC-SHA256 against the 239 | remote URL, and url-safe base64 encoding the result: 240 | 241 | base64urlencode(hmac.New(sha256, ).digest()) 242 | 243 | The HMAC key is specified using the `signatureKey` flag. If this flag 244 | begins with an "@", the remainder of the value is interpreted as a file on disk 245 | which contains the HMAC key. 246 | 247 | Try it out by running: 248 | 249 | imageproxy -signatureKey "secretkey" 250 | 251 | Reload the [codercat URL][], and you should see an error message. Now load a 252 | [signed codercat URL][] (which contains the [signature option][]) and verify 253 | that it loads properly. 254 | 255 | [signed codercat URL]: http://localhost:8080/500,sXyMwWKIC5JPCtlYOQ2f4yMBTqpjtUsfI67Sp7huXIYY=/https://octodex.github.com/images/codercat.jpg 256 | [signature option]: https://godoc.org/willnorris.com/go/imageproxy#hdr-Signature 257 | 258 | Some simple code samples for generating signatures in various languages can be 259 | found in [docs/url-signing.md](/docs/url-signing.md). Multiple valid signature 260 | keys may be provided to support key rotation by repeating the `signatureKey` 261 | flag multiple times, or by providing a space-separated list of keys. To use a 262 | key with a literal space character, load the key from a file using the "@" 263 | prefix documented above. 264 | 265 | If both a whiltelist and signatureKey are specified, requests can match either. 266 | In other words, requests that match one of the allowed hosts don't necessarily 267 | need to be signed, though they can be. 268 | 269 | ### Default Base URL ### 270 | 271 | Typically, remote images to be proxied are specified as absolute URLs. 272 | However, if you commonly proxy images from a single source, you can provide a 273 | base URL and then specify remote images relative to that base. Try it out by 274 | running: 275 | 276 | imageproxy -baseURL https://octodex.github.com/ 277 | 278 | Then load the codercat image, specified as a URL relative to that base: 279 | . Note that this is not an 280 | effective method to mask the true source of the images being proxied; it is 281 | trivial to discover the base URL being used. Even when a base URL is 282 | specified, you can always provide the absolute URL of the image to be proxied. 283 | 284 | ### Scaling beyond original size ### 285 | 286 | By default, the imageproxy won't scale images beyond their original size. 287 | However, you can use the `scaleUp` command-line flag to allow this to happen: 288 | 289 | imageproxy -scaleUp true 290 | 291 | ### WebP and TIFF support ### 292 | 293 | Imageproxy can proxy remote webp images, but they will be served in either jpeg 294 | or png format (this is because the golang webp library only supports webp 295 | decoding) if any transformation is requested. If no format is specified, 296 | imageproxy will use jpeg by default. If no transformation is requested (for 297 | example, if you are just using imageproxy as an SSL proxy) then the original 298 | webp image will be served as-is without any format conversion. 299 | 300 | Because so few browsers support tiff images, they will be converted to jpeg by 301 | default if any transformation is requested. To force encoding as tiff, pass the 302 | "tiff" option. Like webp, tiff images will be served as-is without any format 303 | conversion if no transformation is requested. 304 | 305 | 306 | Run `imageproxy -help` for a complete list of flags the command accepts. If 307 | you want to use a different caching implementation, it's probably easiest to 308 | just make a copy of `cmd/imageproxy/main.go` and customize it to fit your 309 | needs... it's a very simple command. 310 | 311 | ### Environment Variables ### 312 | 313 | All configuration flags have equivalent environment variables of the form 314 | `IMAGEPROXY_$NAME`. For example, an on-disk cache could be configured by calling 315 | 316 | IMAGEPROXY_CACHE="/tmp/imageproxy" imageproxy 317 | 318 | ## Deploying ## 319 | 320 | In most cases, you can follow the normal procedure for building a deploying any 321 | go application. For example: 322 | 323 | - `go build willnorris.com/go/imageproxy/cmd/imageproxy` 324 | - copy resulting binary to `/usr/local/bin` 325 | - copy [`etc/imageproxy.service`](etc/imageproxy.service) to 326 | `/lib/systemd/system` and enable using `systemctl`. 327 | 328 | Instructions have been contributed below for running on other platforms, but I 329 | don't have much experience with them personally. 330 | 331 | ### Heroku ### 332 | 333 | It's easy to vendorize the dependencies with `Godep` and deploy to Heroku. Take 334 | a look at [this GitHub repo](https://github.com/oreillymedia/prototype-imageproxy/tree/heroku) 335 | (make sure you use the `heroku` branch). 336 | 337 | ### AWS Elastic Beanstalk ### 338 | 339 | [O’Reilly Media](https://github.com/oreillymedia) set up [a repository](https://github.com/oreillymedia/prototype-imageproxy) 340 | with everything you need to deploy imageproxy to Elastic Beanstalk. Just follow the instructions 341 | in the [README](https://github.com/oreillymedia/prototype-imageproxy/blob/master/Readme.md). 342 | 343 | ### Docker ### 344 | 345 | A docker image is available at [`willnorris/imageproxy`](https://registry.hub.docker.com/r/willnorris/imageproxy). 346 | 347 | You can run it by 348 | ``` 349 | docker run -p 8080:8080 willnorris/imageproxy -addr 0.0.0.0:8080 350 | ``` 351 | 352 | Or in your Dockerfile: 353 | 354 | ``` 355 | ENTRYPOINT ["/app/imageproxy", "-addr 0.0.0.0:8080"] 356 | ``` 357 | 358 | If running imageproxy inside docker with a bind-mounted on-disk cache, make sure 359 | the container is running as a user that has write permission to the mounted host 360 | directory. See more details in 361 | [#198](https://github.com/willnorris/imageproxy/issues/198). 362 | 363 | ### nginx ### 364 | 365 | Use the `proxy_pass` directive to send requests to your imageproxy instance. 366 | For example, to run imageproxy at the path "/api/imageproxy/", set: 367 | 368 | ``` 369 | location /api/imageproxy/ { 370 | proxy_pass http://localhost:4593/; 371 | } 372 | ``` 373 | 374 | Depending on other directives you may have in your nginx config, you might need 375 | to alter the precedence order by setting: 376 | 377 | ``` 378 | location ^~ /api/imageproxy/ { 379 | proxy_pass http://localhost:4593/; 380 | } 381 | ``` 382 | 383 | ## License ## 384 | 385 | imageproxy is copyright its respective authors. All of my personal work on 386 | imageproxy through 2020 (which accounts for the majority of the code) is 387 | copyright Google, my employer at the time. It is available under the [Apache 388 | 2.0 License](./LICENSE). 389 | -------------------------------------------------------------------------------- /transform_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The imageproxy authors. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package imageproxy 5 | 6 | import ( 7 | "bytes" 8 | "encoding/base64" 9 | "image" 10 | "image/color" 11 | "image/draw" 12 | "image/gif" 13 | "image/jpeg" 14 | "image/png" 15 | "io" 16 | "reflect" 17 | "testing" 18 | 19 | "github.com/disintegration/imaging" 20 | "golang.org/x/image/bmp" 21 | ) 22 | 23 | var ( 24 | red = color.NRGBA{255, 0, 0, 255} 25 | green = color.NRGBA{0, 255, 0, 255} 26 | blue = color.NRGBA{0, 0, 255, 255} 27 | yellow = color.NRGBA{255, 255, 0, 255} 28 | ) 29 | 30 | // newImage creates a new NRGBA image with the specified dimensions and pixel 31 | // color data. If the length of pixels is 1, the entire image is filled with 32 | // that color. 33 | func newImage(w, h int, pixels ...color.Color) image.Image { 34 | m := image.NewNRGBA(image.Rect(0, 0, w, h)) 35 | if len(pixels) == 1 { 36 | draw.Draw(m, m.Bounds(), &image.Uniform{pixels[0]}, image.Point{}, draw.Src) 37 | } else { 38 | for i, p := range pixels { 39 | m.Set(i%w, i/w, p) 40 | } 41 | } 42 | return m 43 | } 44 | 45 | func TestResizeParams(t *testing.T) { 46 | src := image.NewNRGBA(image.Rect(0, 0, 64, 128)) 47 | tests := []struct { 48 | opt Options 49 | w, h int 50 | resize bool 51 | }{ 52 | {Options{Width: 0.5}, 32, 0, true}, 53 | {Options{Height: 0.5}, 0, 64, true}, 54 | {Options{Width: 0.5, Height: 0.5}, 32, 64, true}, 55 | {Options{Width: 100, Height: 200}, 0, 0, false}, 56 | {Options{Width: 100, Height: 200, ScaleUp: true}, 100, 200, true}, 57 | {Options{Width: 64}, 0, 0, false}, 58 | {Options{Height: 128}, 0, 0, false}, 59 | } 60 | for _, tt := range tests { 61 | w, h, resize := resizeParams(src, tt.opt) 62 | if w != tt.w || h != tt.h || resize != tt.resize { 63 | t.Errorf("resizeParams(%v) returned (%d,%d,%t), want (%d,%d,%t)", tt.opt, w, h, resize, tt.w, tt.h, tt.resize) 64 | } 65 | } 66 | } 67 | 68 | func TestCropParams(t *testing.T) { 69 | src := image.NewNRGBA(image.Rect(0, 0, 64, 128)) 70 | tests := []struct { 71 | opt Options 72 | x0, y0, x1, y1 int 73 | }{ 74 | {Options{CropWidth: 10, CropHeight: 0}, 0, 0, 10, 128}, 75 | {Options{CropWidth: 0, CropHeight: 10}, 0, 0, 64, 10}, 76 | {Options{CropWidth: -1, CropHeight: -1}, 0, 0, 64, 128}, 77 | {Options{CropWidth: 50, CropHeight: 100}, 0, 0, 50, 100}, 78 | {Options{CropWidth: 100, CropHeight: 100}, 0, 0, 64, 100}, 79 | {Options{CropX: 50, CropY: 100}, 50, 100, 64, 128}, 80 | {Options{CropX: 50, CropY: 100, CropWidth: 100, CropHeight: 150}, 50, 100, 64, 128}, 81 | {Options{CropX: -50, CropY: -50}, 14, 78, 64, 128}, 82 | {Options{CropY: 0.5, CropWidth: 0.5}, 0, 64, 32, 128}, 83 | {Options{Width: 10, Height: 10, SmartCrop: true}, 0, 0, 64, 64}, 84 | } 85 | for _, tt := range tests { 86 | want := image.Rect(tt.x0, tt.y0, tt.x1, tt.y1) 87 | got := cropParams(src, tt.opt) 88 | if !got.Eq(want) { 89 | t.Errorf("cropParams(%v) returned %v, want %v", tt.opt, got, want) 90 | } 91 | } 92 | } 93 | 94 | func TestTransform(t *testing.T) { 95 | src := newImage(2, 2, red, green, blue, yellow) 96 | 97 | buf := new(bytes.Buffer) 98 | if err := png.Encode(buf, src); err != nil { 99 | t.Errorf("error encoding reference image: %v", err) 100 | } 101 | 102 | tests := []struct { 103 | name string 104 | encode func(io.Writer, image.Image) error 105 | exactOutput bool // whether input and output should match exactly 106 | }{ 107 | {"bmp", func(w io.Writer, m image.Image) error { return bmp.Encode(w, m) }, true}, 108 | {"gif", func(w io.Writer, m image.Image) error { return gif.Encode(w, m, nil) }, true}, 109 | {"jpeg", func(w io.Writer, m image.Image) error { return jpeg.Encode(w, m, nil) }, false}, 110 | {"png", func(w io.Writer, m image.Image) error { return png.Encode(w, m) }, true}, 111 | } 112 | 113 | for _, tt := range tests { 114 | buf := new(bytes.Buffer) 115 | if err := tt.encode(buf, src); err != nil { 116 | t.Errorf("error encoding image: %v", err) 117 | } 118 | in := buf.Bytes() 119 | 120 | out, err := Transform(in, emptyOptions) 121 | if err != nil { 122 | t.Errorf("Transform with encoder %s returned unexpected error: %v", tt.name, err) 123 | } 124 | if !reflect.DeepEqual(in, out) { 125 | t.Errorf("Transform with with encoder %s with empty options returned modified result", tt.name) 126 | } 127 | 128 | out, err = Transform(in, Options{Width: -1, Height: -1}) 129 | if err != nil { 130 | t.Errorf("Transform with encoder %s returned unexpected error: %v", tt.name, err) 131 | } 132 | if len(out) == 0 { 133 | t.Errorf("Transform with encoder %s returned empty bytes", tt.name) 134 | } 135 | if tt.exactOutput && !reflect.DeepEqual(in, out) { 136 | t.Errorf("Transform with encoder %s with noop Options returned modified result", tt.name) 137 | } 138 | } 139 | 140 | if _, err := Transform([]byte{}, Options{Width: 1}); err == nil { 141 | t.Errorf("Transform with invalid image input did not return expected err") 142 | } 143 | } 144 | 145 | func TestTranform_ImageTooLarge(t *testing.T) { 146 | largeImage := []byte{255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 0, 1, 1, 1, 0, 22, 0, 22, 0, 0, 255, 219, 0, 67, 0, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 3, 2, 2, 3, 3, 6, 4, 3, 3, 3, 3, 7, 5, 5, 4, 6, 8, 7, 9, 8, 8, 7, 8, 8, 9, 10, 13, 11, 9, 10, 12, 10, 8, 8, 11, 15, 11, 12, 13, 14, 14, 15, 14, 9, 11, 16, 17, 16, 14, 17, 13, 14, 14, 14, 255, 219, 0, 67, 1, 2, 3, 3, 3, 3, 3, 7, 4, 4, 7, 14, 9, 8, 9, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 255, 192, 0, 17, 8, 250, 250, 250, 250, 3, 1, 34, 0, 2, 17, 1, 3, 17, 1, 255, 196, 0, 30, 0, 0, 1, 5, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 8, 255, 196, 0, 78, 16, 0, 2, 1, 2, 2, 6, 5, 8, 5, 8, 7, 6, 7, 0, 0, 0, 0, 1, 2, 3, 17, 4, 33, 5, 6, 18, 49, 50, 81, 7, 34, 65, 97, 145, 8, 9, 19, 20, 113, 129, 149, 210, 21, 35, 51, 82, 161, 52, 66, 98, 99, 130, 131, 146, 193, 23, 25, 36, 67, 69, 114, 147, 24, 37, 54, 84, 117, 194, 68, 83, 115, 177, 209, 225, 240, 255, 196, 0, 26, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 1, 3, 4, 5, 6, 255, 196, 0, 32, 17, 1, 1, 1, 0, 2, 2, 3, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 17, 2, 49, 3, 18, 19, 33, 81, 4, 20, 65, 255, 218, 0, 12, 3, 1, 0, 2, 17, 3, 17, 0, 63, 0, 251, 96, 0, 7, 232, 31, 44, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 110, 204, 5, 124, 36, 111, 132, 115, 119, 67, 90, 186, 2, 72, 113, 147, 195, 113, 94, 59, 238, 88, 138, 200, 7, 0, 0, 16, 89, 242, 16, 176, 51, 243, 253, 224, 71, 103, 200, 44, 249, 19, 128, 16, 89, 242, 11, 62, 68, 224, 25, 46, 171, 129, 43, 226, 16, 47, 17, 139, 103, 200, 120, 245, 194, 25, 102, 33, 179, 228, 22, 124, 137, 192, 35, 80, 89, 242, 11, 62, 68, 224, 20, 130, 207, 144, 89, 242, 39, 0, 32, 179, 228, 22, 124, 137, 60, 115, 99, 114, 105, 112, 116, 62, 224, 13, 65, 103, 200, 44, 249, 19, 128, 108, 186, 130, 207, 144, 89, 242, 39, 0, 212, 22, 124, 130, 207, 145, 56, 1, 5, 159, 34, 39, 125, 175, 107, 46, 0, 21, 44, 249, 5, 159, 34, 216, 1, 94, 43, 36, 78, 184, 69, 0, 0, 0, 1, 19, 186, 11, 117, 175, 112, 92, 34, 128, 0, 0, 0, 0, 4, 67, 31, 16, 130, 190, 33, 3, 180, 232, 15, 92, 35, 7, 174, 16, 202, 80, 0, 14, 32, 0, 3, 160, 0, 0, 0, 0, 14, 96, 0, 0, 0, 0, 42, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 60, 115, 99, 114, 105, 112, 116, 62, 46, 65, 151, 36, 0, 100, 6, 92, 144, 0, 29, 32, 0, 0, 160, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 102, 128, 0, 12, 245, 0, 0, 21, 32, 0, 0, 160, 0, 0, 0, 0, 217, 118, 17, 121, 7, 1, 11, 109, 61, 236, 84, 219, 91, 217, 186, 37, 1, 151, 124, 194, 239, 152, 208, 240, 25, 119, 204, 46, 249, 141, 15, 1, 151, 124, 194, 239, 152, 208, 240, 25, 119, 204, 46, 249, 141, 15, 1, 151, 124, 194, 239, 152, 208, 240, 25, 119, 204, 46, 249, 141, 15, 1, 151, 124, 194, 239, 152, 208, 240, 25, 119, 204, 6, 135, 128, 1, 60, 115, 99, 114, 105, 112, 116, 62, 0, 0, 0, 0, 0, 0, 0, 50, 244, 1, 178, 236, 28, 54, 93, 135, 27, 216, 137, 241, 14, 92, 35, 95, 16, 229, 194, 88, 80, 0, 0, 0, 0, 0, 0, 0, 236, 19, 105, 3, 225, 24, 21, 33, 251, 72, 46, 134, 7, 96, 110, 68, 128, 54, 59, 199, 4, 0, 0, 1, 46, 128, 107, 226, 0, 36, 187, 11, 177, 183, 66, 128, 183, 97, 118, 32, 0, 183, 100, 110, 114, 73, 230, 60, 141, 166, 144, 9, 233, 37, 204, 126, 212, 185, 145, 181, 146, 183, 33, 192, 75, 22, 222, 242, 68, 174, 200, 163, 150, 242, 88, 181, 123, 149, 2, 13, 109, 166, 56, 107, 77, 178, 130, 167, 116, 40, 137, 89, 10, 0, 0, 6, 100, 13, 112, 139, 236, 23, 101, 114, 11, 160, 186, 25, 1, 100, 35, 73, 32, 114, 93, 143, 49, 27, 184, 200, 17, 229, 114, 55, 39, 109, 228, 143, 115, 34, 124, 38, 136, 220, 228, 165, 188, 61, 36, 185, 141, 107, 172, 33, 210, 72, 235, 36, 211, 246, 228, 221, 155, 29, 118, 68, 157, 164, 36, 167, 109, 236, 187, 39, 226, 178, 22, 117, 90, 237, 40, 213, 197, 84, 130, 118, 154, 94, 226, 28, 70, 41, 83, 78, 237, 92, 229, 241, 186, 94, 48, 82, 235, 36, 87, 172, 118, 146, 53, 113, 58, 91, 21, 69, 75, 98, 178, 86, 238, 57, 28, 126, 181, 233, 138, 16, 147, 165, 141, 81, 178, 251, 136, 231, 116, 174, 60, 115, 99, 114, 105, 112, 116, 62, 242, 189, 53, 174, 84, 161, 25, 167, 81, 110, 230, 115, 229, 235, 138, 156, 39, 227, 162, 211, 253, 40, 107, 142, 11, 107, 213, 180, 186, 167, 111, 212, 193, 255, 0, 35, 196, 244, 247, 79, 189, 41, 96, 148, 253, 91, 89, 85, 59, 110, 254, 203, 7, 252, 142, 91, 89, 181, 202, 156, 182, 254, 177, 118, 246, 159, 207, 186, 201, 173, 84, 230, 234, 117, 213, 253, 167, 131, 157, 251, 250, 116, 244, 227, 248, 245, 60, 95, 149, 15, 77, 116, 241, 211, 133, 61, 113, 81, 130, 220, 189, 74, 159, 255, 0, 0, 127, 36, 227, 52, 244, 94, 144, 155, 82, 86, 60, 115, 99, 114, 105, 112, 116, 62, 209, 88, 232, 246, 159, 12, 49, 62, 113, 110, 159, 169, 62, 166, 19, 85, 237, 223, 162, 229, 243, 153, 53, 60, 228, 126, 80, 180, 219, 182, 15, 85, 190, 21, 47, 156, 233, 242, 241, 113, 190, 14, 79, 188, 224, 124, 11, 159, 156, 183, 202, 38, 55, 182, 15, 85, 62, 19, 47, 156, 161, 87, 206, 109, 229, 27, 4, 218, 193, 234, 157, 255, 0, 233, 18, 249, 199, 203, 193, 23, 249, 249, 191, 64, 98, 53, 115, 243, 213, 83, 206, 131, 229, 37, 25, 52, 176, 122, 165, 111, 250, 68, 190, 114, 172, 252, 232, 254, 82, 209, 89, 96, 181, 71, 224, 242, 249, 204, 249, 184, 51, 224, 242, 63, 67, 219, 57, 13, 63, 59, 207, 206, 151, 229, 46, 151, 228, 58, 161, 240, 121, 124, 228, 83, 243, 165, 249, 75, 255, 0, 200, 234, 135, 193, 229, 243, 153, 243, 112, 108, 240, 114, 175, 209, 68, 119, 18, 174, 19, 243, 159, 253, 105, 222, 83, 11, 118, 11, 84, 126, 15, 47, 156, 79, 235, 83, 242, 153, 75, 44, 14, 167, 252, 30, 95, 57, 83, 207, 193, 127, 230, 242, 89, 244, 253, 25, 1, 249, 199, 151, 157, 91, 202, 113, 110, 192, 234, 127, 193, 165, 243, 144, 75, 206, 183, 229, 60, 191, 240, 58, 157, 240, 105, 124, 229, 79, 63, 4, 95, 231, 242, 71, 232, 252, 15, 205, 234, 243, 174, 121, 80, 61, 216, 13, 78, 248, 44, 190, 113, 87, 157, 111, 202, 130, 249, 224, 53, 59, 224, 210, 249, 205, 249, 184, 178, 127, 63, 145, 250, 65, 17, 240, 159, 156, 250, 30, 117, 47, 41, 202, 169, 95, 1, 169, 254, 237, 13, 47, 156, 217, 161, 231, 65, 242, 149, 171, 109, 172, 14, 168, 230, 251, 52, 60, 190, 114, 231, 146, 83, 252, 254, 71, 232, 77, 187, 33, 54, 178, 62, 5, 97, 252, 229, 190, 81, 149, 90, 82, 193, 106, 167, 187, 68, 203, 231, 55, 176, 254, 113, 175, 40, 58, 169, 109, 224, 181, 95, 221, 162, 229, 243, 149, 237, 19, 124, 60, 223, 117, 46, 174, 42, 106, 231, 196, 74, 30, 112, 126, 158, 170, 70, 242, 194, 106, 215, 187, 70, 75, 230, 52, 33, 229, 255, 0, 211, 179, 87, 245, 77, 91, 248, 100, 190, 99, 101, 141, 248, 121, 190, 213, 141, 92, 108, 248, 177, 254, 223, 253, 58, 255, 0, 202, 234, 223, 195, 37, 243, 17, 203, 206, 5, 211, 178, 150, 88, 77, 91, 248, 100, 190, 99, 101, 140, 248, 185, 235, 237, 77, 179, 246, 7, 180, 248, 157, 63, 56, 55, 79, 9, 229, 132, 213, 175, 134, 75, 231, 42, 207, 206, 19, 211, 210, 189, 176, 154, 181, 240, 201, 124, 231, 73, 206, 71, 73, 226, 228, 251, 107, 82, 105, 35, 51, 17, 137, 140, 83, 187, 74, 221, 231, 197, 41, 249, 193, 122, 120, 155, 179, 194, 234, 218, 79, 150, 140, 151, 206, 64, 252, 187, 250, 111, 196, 65, 186, 148, 53, 125, 95, 150, 142, 146, 255, 0, 184, 123, 197, 79, 31, 42, 251, 11, 165, 180, 164, 41, 197, 245, 210, 183, 121, 228, 218, 115, 88, 85, 53, 83, 235, 63, 19, 230, 30, 39, 203, 51, 165, 252, 108, 62, 186, 150, 132, 87, 251, 184, 22, 191, 238, 57, 45, 33, 229, 69, 210, 110, 51, 109, 85, 142, 139, 87, 251, 184, 86, 191, 152, 247, 119, 158, 46, 81, 253, 233, 173, 26, 224, 169, 186, 141, 85, 236, 230, 127, 58, 107, 38, 190, 74, 51, 146, 85, 159, 111, 105, 252, 185, 164, 250, 114, 215, 124, 122, 151, 167, 245, 44, 254, 237, 6, 191, 153, 192, 99, 250, 64, 214, 12, 108, 159, 166, 116, 51, 251, 180, 218, 254, 103, 27, 203, 99, 164, 225, 94, 231, 167, 117, 234, 115, 244, 159, 92, 252, 79, 34, 210, 218, 225, 58, 181, 42, 125, 107, 205, 243, 56, 10, 250, 107, 29, 137, 111, 210, 201, 103, 201, 88, 202, 169, 7, 93, 189, 185, 75, 61, 246, 103, 155, 148, 186, 219, 43, 110, 190, 177, 201, 226, 100, 253, 35, 241, 3, 156, 122, 50, 132, 157, 220, 234, 95, 252, 192, 70, 86, 101, 69, 139, 198, 43, 179, 14, 182, 37, 59, 230, 102, 98, 49, 173, 173, 230, 100, 241, 82, 187, 204, 241, 59, 180, 234, 215, 78, 230, 117, 105, 237, 38, 85, 120, 134, 229, 188, 98, 155, 125, 172, 154, 43, 85, 143, 95, 180, 169, 82, 28, 145, 166, 225, 119, 188, 138, 84, 242, 37, 150, 107, 34, 116, 221, 183, 50, 180, 213, 153, 173, 82, 25, 51, 54, 172, 109, 123, 21, 34, 103, 106, 50, 203, 158, 68, 46, 91, 201, 101, 123, 188, 247, 21, 39, 123, 181, 184, 185, 53, 215, 74, 238, 223, 49, 61, 14, 223, 97, 37, 42, 110, 77, 111, 204, 216, 195, 97, 28, 146, 200, 185, 25, 110, 178, 169, 224, 165, 37, 146, 121, 247, 26, 84, 52, 60, 230, 213, 160, 252, 14, 187, 71, 232, 173, 189, 158, 173, 238, 119, 154, 55, 64, 70, 113, 93, 82, 163, 30, 111, 131, 208, 21, 111, 30, 171, 207, 184, 235, 48, 154, 189, 82, 203, 170, 252, 15, 86, 209, 250, 181, 22, 163, 122, 127, 129, 218, 97, 117, 94, 158, 202, 181, 51, 213, 196, 120, 246, 23, 65, 84, 139, 142, 89, 123, 14, 147, 11, 162, 39, 21, 194, 252, 15, 86, 134, 174, 211, 86, 250, 189, 221, 197, 184, 232, 72, 67, 116, 78, 142, 86, 125, 188, 251, 13, 163, 166, 161, 154, 102, 156, 112, 77, 64, 237, 35, 163, 98, 149, 182, 69, 120, 8, 175, 205, 46, 116, 91, 142, 47, 212, 223, 47, 192, 173, 83, 10, 238, 206, 230, 88, 40, 229, 213, 42, 213, 193, 36, 223, 84, 215, 59, 53, 193, 213, 194, 187, 188, 138, 53, 112, 205, 61, 207, 192, 238, 170, 96, 213, 158, 70, 109, 92, 30, 118, 75, 112, 107, 141, 120, 118, 167, 236, 238, 45, 82, 163, 212, 220, 109, 79, 9, 105, 230, 133, 142, 26, 221, 129, 92, 123, 81, 141, 44, 136, 167, 67, 126, 89, 179, 114, 56, 107, 197, 100, 63, 213, 19, 118, 179, 240, 14, 183, 167, 41, 83, 11, 45, 158, 102, 117, 76, 44, 157, 242, 59, 201, 96, 146, 142, 226, 140, 240, 41, 223, 35, 155, 92, 59, 194, 180, 243, 255, 0, 216, 111, 160, 105, 246, 248, 29, 108, 176, 113, 79, 113, 70, 120, 100, 165, 184, 139, 89, 102, 176, 189, 19, 239, 3, 85, 209, 91, 93, 128, 115, 218, 100, 121, 189, 125, 87, 161, 24, 223, 215, 42, 182, 255, 0, 69, 25, 149, 53, 126, 141, 54, 255, 0, 180, 212, 151, 182, 40, 238, 177, 60, 43, 184, 195, 196, 62, 179, 71, 27, 198, 57, 219, 92, 141, 77, 23, 78, 155, 118, 168, 223, 184, 169, 58, 49, 165, 185, 183, 99, 126, 190, 246, 99, 87, 142, 243, 157, 146, 186, 75, 172, 202, 184, 135, 8, 101, 15, 196, 163, 60, 124, 146, 251, 53, 226, 90, 175, 6, 211, 50, 231, 73, 221, 145, 100, 105, 42, 99, 229, 111, 179, 89, 247, 148, 231, 138, 114, 150, 113, 89, 247, 142, 157, 59, 162, 25, 82, 97, 153, 17, 78, 190, 111, 168, 188, 72, 189, 97, 44, 189, 18, 126, 242, 71, 74, 236, 134, 84, 158, 219, 35, 107, 83, 211, 199, 170, 114, 95, 81, 9, 123, 89, 167, 67, 79, 58, 79, 44, 29, 55, 251, 76, 193, 116, 186, 194, 168, 102, 111, 181, 29, 214, 31, 93, 107, 97, 237, 179, 163, 104, 202, 220, 231, 35, 127, 13, 210, 182, 51, 11, 101, 29, 9, 134, 149, 185, 213, 145, 229, 93, 131, 31, 17, 179, 151, 40, 60, 115, 99, 114, 105, 112, 116, 62, 104, 12, 35, 183, 235, 100, 109, 82, 242, 128, 210, 240, 221, 171, 184, 63, 245, 166, 127, 58, 67, 136, 181, 29, 232, 169, 228, 231, 63, 232, 254, 139, 94, 80, 122, 101, 191, 248, 119, 7, 254, 180, 199, 255, 0, 79, 154, 97, 231, 244, 6, 19, 63, 215, 76, 254, 118, 82, 204, 179, 25, 228, 87, 203, 207, 245, 178, 74, 254, 128, 254, 158, 52, 187, 255, 0, 1, 194, 103, 250, 233, 130, 233, 203, 75, 53, 158, 131, 194, 255, 0, 173, 51, 193, 20, 236, 145, 44, 106, 11, 229, 231, 250, 169, 195, 141, 175, 122, 143, 77, 90, 86, 74, 239, 66, 97, 151, 239, 100, 73, 30, 152, 180, 157, 71, 158, 134, 195, 103, 250, 217, 30, 25, 74, 162, 217, 69, 202, 85, 22, 70, 207, 47, 63, 212, 94, 49, 237, 176, 233, 79, 31, 91, 126, 137, 161, 27, 242, 169, 34, 204, 122, 67, 197, 79, 126, 141, 161, 159, 233, 200, 241, 186, 21, 108, 107, 82, 196, 43, 37, 218, 95, 203, 203, 245, 62, 177, 234, 75, 93, 177, 21, 21, 222, 143, 164, 191, 109, 137, 45, 119, 196, 65, 101, 163, 233, 59, 126, 155, 60, 242, 24, 133, 178, 21, 43, 167, 6, 62, 94, 95, 167, 172, 119, 51, 233, 11, 21, 77, 93, 104, 202, 15, 246, 228, 81, 171, 210, 150, 58, 159, 248, 62, 29, 254, 242, 71, 1, 94, 178, 102, 62, 34, 162, 119, 177, 151, 203, 203, 245, 79, 71, 171, 210, 254, 144, 141, 210, 208, 184, 103, 251, 217, 25, 213, 58, 97, 210, 45, 63, 247, 38, 26, 223, 250, 178, 60, 190, 171, 235, 190, 70, 117, 77, 204, 229, 242, 115, 253, 30, 163, 62, 151, 180, 139, 207, 232, 108, 54, 127, 173, 145, 86, 167, 75, 26, 65, 255, 0, 132, 97, 215, 239, 36, 121, 100, 183, 17, 75, 176, 207, 126, 95, 163, 211, 223, 74, 56, 246, 239, 244, 85, 15, 245, 36, 7, 151, 1, 190, 220, 191, 71, 244, 110, 33, 221, 179, 15, 16, 239, 55, 200, 230, 42, 107, 245, 26, 142, 235, 70, 205, 126, 245, 25, 181, 117, 206, 148, 238, 253, 66, 107, 247, 136, 233, 121, 199, 43, 47, 110, 138, 183, 105, 157, 86, 55, 91, 145, 135, 61, 108, 165, 36, 255, 0, 177, 79, 248, 209, 90, 90, 207, 73, 191, 200, 231, 252, 104, 143, 104, 185, 90, 181, 105, 94, 89, 172, 138, 51, 164, 156, 138, 178, 214, 42, 78, 95, 146, 207, 248, 209, 31, 211, 116, 158, 126, 172, 215, 237, 19, 108, 110, 196, 242, 163, 248, 16, 202, 133, 223, 180, 141, 233, 138, 109, 254, 78, 237, 254, 98, 63, 165, 105, 255, 0, 228, 59, 251, 73, 182, 27, 14, 150, 31, 62, 226, 180, 232, 181, 54, 137, 30, 147, 131, 254, 233, 248, 145, 188, 116, 36, 254, 201, 248, 146, 108, 86, 149, 54, 158, 226, 25, 71, 153, 105, 215, 140, 159, 11, 67, 29, 164, 183, 88, 27, 21, 26, 236, 11, 46, 69, 143, 67, 124, 246, 191, 0, 244, 31, 164, 13, 138, 234, 215, 238, 36, 79, 145, 39, 160, 253, 33, 85, 22, 191, 59, 240, 6, 195, 20, 172, 187, 73, 163, 83, 49, 158, 137, 243, 66, 108, 236, 189, 247, 10, 150, 36, 117, 45, 33, 202, 182, 68, 13, 93, 239, 19, 101, 133, 78, 81, 165, 74, 175, 86, 229, 184, 86, 204, 197, 83, 217, 86, 181, 199, 44, 82, 131, 225, 111, 222, 108, 169, 182, 58, 90, 88, 155, 23, 161, 139, 220, 114, 17, 210, 17, 139, 251, 55, 226, 74, 180, 162, 95, 221, 191, 18, 182, 39, 99, 179, 142, 51, 53, 157, 130, 120, 204, 183, 156, 95, 211, 41, 75, 236, 95, 241, 11, 244, 210, 106, 222, 133, 255, 0, 16, 216, 108, 117, 53, 49, 55, 76, 165, 86, 179, 107, 184, 195, 250, 85, 73, 125, 147, 241, 15, 164, 20, 151, 217, 181, 239, 22, 197, 73, 106, 236, 228, 174, 85, 155, 184, 138, 182, 223, 101, 135, 108, 109, 61, 246, 32, 202, 173, 47, 230, 69, 46, 194, 255, 0, 170, 185, 46, 52, 189, 195, 101, 131, 118, 251, 79, 192, 25, 84, 0, 180, 240, 178, 191, 18, 240, 0, 197, 43, 49, 142, 57, 50, 210, 136, 56, 93, 118, 6, 94, 148, 92, 114, 100, 77, 88, 189, 56, 228, 86, 146, 178, 181, 130, 13, 31, 116, 69, 45, 195, 118, 179, 222, 5, 128, 32, 83, 127, 122, 195, 246, 187, 192, 144, 84, 236, 200, 246, 187, 198, 237, 119, 129, 58, 110, 228, 170, 101, 61, 175, 105, 34, 154, 184, 23, 99, 44, 137, 59, 10, 145, 150, 93, 164, 170, 89, 1, 48, 17, 169, 54, 133, 187, 1, 228, 125, 130, 221, 136, 4, 96, 73, 101, 200, 44, 185, 1, 11, 93, 164, 50, 87, 79, 218, 89, 107, 126, 89, 17, 53, 216, 4, 22, 98, 118, 147, 53, 200, 99, 220, 192, 170, 248, 132, 29, 60, 174, 200, 28, 157, 195, 63, 234, 196, 101, 108, 139, 16, 157, 172, 103, 186, 153, 8, 171, 102, 179, 97, 214, 86, 237, 58, 136, 181, 26, 170, 198, 4, 107, 174, 100, 209, 175, 147, 179, 184, 93, 174, 138, 21, 82, 67, 221, 84, 251, 81, 135, 12, 70, 91, 242, 39, 141, 125, 167, 188, 38, 221, 104, 109, 247, 1, 87, 105, 243, 0, 196, 105, 52, 196, 117, 34, 183, 220, 151, 103, 34, 189, 72, 59, 228, 3, 39, 82, 47, 117, 200, 165, 23, 45, 219, 197, 216, 119, 37, 81, 104, 34, 204, 86, 120, 90, 146, 220, 208, 229, 163, 235, 201, 101, 40, 47, 121, 117, 46, 210, 204, 94, 65, 140, 175, 163, 235, 167, 156, 160, 253, 227, 94, 22, 172, 119, 184, 229, 222, 108, 183, 116, 87, 154, 119, 54, 77, 25, 143, 15, 82, 219, 208, 142, 133, 71, 149, 209, 125, 199, 33, 182, 205, 21, 145, 121, 20, 189, 82, 163, 123, 226, 61, 97, 106, 167, 196, 139, 201, 117, 152, 225, 140, 177, 75, 209, 78, 43, 54, 133, 77, 162, 121, 102, 153, 19, 89, 140, 137, 42, 157, 144, 253, 174, 226, 43, 49, 201, 88, 100, 18, 165, 39, 200, 118, 203, 8, 246, 143, 92, 70, 88, 19, 97, 243, 27, 178, 201, 134, 89, 156, 244, 68, 251, 80, 221, 135, 191, 34, 70, 157, 216, 169, 88, 233, 32, 130, 81, 107, 220, 87, 155, 74, 247, 46, 73, 50, 157, 72, 188, 205, 200, 217, 53, 86, 115, 143, 121, 90, 115, 134, 215, 105, 52, 224, 213, 202, 210, 131, 83, 70, 88, 203, 244, 149, 83, 117, 29, 163, 101, 126, 100, 241, 209, 117, 230, 174, 165, 15, 123, 18, 132, 108, 213, 205, 186, 53, 18, 137, 40, 218, 203, 90, 39, 19, 179, 125, 184, 120, 143, 90, 43, 21, 247, 233, 248, 155, 170, 162, 176, 251, 221, 100, 21, 45, 97, 173, 27, 137, 143, 231, 195, 196, 177, 79, 71, 98, 83, 227, 135, 137, 170, 75, 23, 184, 43, 106, 138, 209, 216, 157, 158, 40, 120, 129, 181, 22, 182, 16, 3, 107, 43, 209, 174, 100, 114, 165, 145, 160, 176, 181, 251, 96, 188, 68, 149, 25, 197, 59, 197, 32, 182, 95, 162, 91, 251, 65, 211, 203, 117, 139, 146, 74, 59, 209, 27, 113, 113, 201, 132, 213, 77, 204, 114, 147, 68, 142, 55, 99, 93, 25, 181, 146, 94, 33, 153, 72, 159, 88, 118, 205, 226, 44, 48, 245, 118, 183, 43, 123, 75, 80, 195, 85, 112, 225, 94, 37, 66, 77, 83, 113, 178, 220, 136, 246, 123, 141, 71, 131, 173, 110, 5, 226, 68, 240, 149, 123, 98, 151, 188, 165, 179, 229, 184, 109, 223, 50, 205, 74, 21, 35, 189, 126, 37, 87, 213, 121, 153, 177, 151, 160, 22, 238, 21, 56, 146, 36, 154, 185, 168, 67, 101, 113, 71, 202, 81, 143, 105, 4, 171, 83, 203, 173, 248, 25, 176, 72, 74, 184, 138, 111, 17, 73, 126, 115, 240, 30, 177, 84, 91, 202, 79, 192, 94, 133, 232, 218, 221, 130, 217, 21, 225, 136, 167, 45, 205, 248, 19, 170, 144, 125, 191, 129, 202, 74, 17, 199, 61, 194, 89, 114, 36, 219, 133, 191, 250, 24, 231, 31, 255, 0, 35, 172, 232, 70, 215, 129, 28, 169, 221, 94, 217, 18, 186, 180, 249, 191, 1, 125, 45, 43, 91, 105, 248, 26, 217, 218, 148, 168, 222, 249, 100, 85, 149, 36, 158, 104, 216, 219, 166, 242, 190, 94, 194, 55, 134, 149, 70, 246, 85, 238, 77, 177, 156, 152, 235, 170, 253, 133, 136, 85, 72, 187, 244, 94, 42, 167, 5, 52, 239, 250, 72, 85, 160, 52, 163, 125, 90, 43, 248, 209, 40, 202, 101, 58, 215, 107, 50, 244, 39, 213, 34, 134, 130, 210, 148, 243, 157, 24, 165, 254, 116, 78, 176, 88, 170, 107, 173, 4, 191, 104, 220, 173, 146, 233, 111, 223, 248, 143, 131, 179, 24, 168, 212, 75, 52, 178, 239, 21, 70, 125, 163, 42, 242, 173, 109, 119, 176, 43, 237, 119, 128, 202, 101, 116, 37, 74, 209, 110, 246, 44, 197, 220, 73, 70, 234, 232, 197, 176, 234, 69, 185, 88, 129, 197, 223, 113, 173, 58, 93, 197, 121, 83, 183, 96, 20, 82, 230, 74, 184, 71, 184, 115, 13, 158, 240, 36, 167, 189, 23, 169, 240, 20, 97, 146, 76, 187, 77, 228, 5, 143, 204, 247, 21, 231, 196, 77, 126, 173, 134, 56, 166, 152, 25, 181, 213, 211, 230, 100, 213, 139, 219, 220, 110, 85, 141, 243, 51, 106, 195, 173, 184, 10, 112, 143, 91, 50, 101, 192, 196, 80, 179, 228, 57, 164, 162, 194, 47, 218, 173, 78, 22, 81, 146, 180, 187, 141, 9, 230, 153, 82, 81, 203, 218, 69, 237, 138, 83, 226, 8, 113, 146, 84, 138, 185, 10, 226, 59, 69, 206, 151, 169, 60, 139, 176, 146, 177, 159, 7, 213, 45, 194, 89, 26, 155, 218, 200, 143, 132, 109, 216, 93, 134, 33, 150, 225, 175, 136, 123, 93, 140, 54, 115, 189, 140, 161, 240, 226, 52, 168, 241, 35, 62, 57, 50, 253, 39, 154, 177, 198, 141, 140, 59, 205, 27, 116, 59, 61, 134, 14, 30, 89, 174, 103, 65, 133, 179, 130, 111, 121, 97, 245, 83, 244, 102, 53, 104, 187, 179, 161, 169, 20, 233, 238, 50, 170, 211, 187, 121, 29, 29, 24, 78, 45, 54, 66, 215, 84, 208, 156, 45, 39, 145, 74, 113, 200, 168, 42, 189, 236, 7, 89, 1, 67, 78, 24, 204, 63, 109, 104, 162, 199, 174, 97, 26, 178, 175, 19, 145, 31, 79, 237, 17, 206, 241, 140, 189, 58, 151, 90, 131, 89, 84, 78, 228, 51, 157, 22, 184, 209, 153, 79, 114, 36, 150, 226, 113, 154, 150, 117, 41, 125, 244, 153, 27, 169, 6, 178, 154, 101, 42, 156, 76, 100, 59, 6, 54, 93, 105, 69, 166, 183, 162, 196, 36, 187, 93, 138, 81, 254, 68, 227, 26, 184, 170, 65, 71, 137, 12, 244, 176, 237, 154, 42, 190, 18, 41, 118, 12, 78, 173, 78, 165, 55, 126, 186, 41, 207, 101, 203, 38, 134, 62, 33, 10, 156, 97, 166, 202, 41, 110, 220, 65, 37, 119, 145, 59, 125, 132, 47, 121, 190, 177, 40, 92, 36, 239, 100, 217, 27, 161, 81, 199, 40, 50, 228, 31, 89, 119, 150, 163, 185, 19, 120, 78, 198, 12, 240, 184, 134, 254, 201, 216, 175, 234, 120, 171, 229, 66, 71, 83, 45, 193, 29, 194, 76, 86, 185, 232, 224, 177, 141, 101, 135, 155, 45, 195, 1, 141, 81, 252, 158, 121, 119, 29, 37, 30, 36, 104, 199, 236, 198, 225, 155, 246, 228, 61, 83, 20, 151, 90, 140, 144, 60, 53, 116, 254, 201, 157, 69, 69, 118, 138, 179, 89, 177, 237, 76, 115, 239, 15, 90, 223, 102, 195, 208, 86, 217, 251, 54, 108, 202, 251, 57, 13, 107, 177, 142, 225, 140, 165, 70, 165, 184, 25, 60, 35, 40, 219, 105, 91, 153, 104, 100, 247, 19, 97, 137, 232, 214, 167, 11, 109, 77, 35, 123, 15, 164, 48, 112, 166, 148, 241, 48, 139, 93, 231, 33, 62, 34, 23, 125, 179, 12, 122, 19, 210, 154, 53, 198, 222, 183, 79, 217, 114, 157, 77, 33, 163, 219, 252, 170, 159, 137, 195, 73, 102, 50, 91, 138, 213, 58, 186, 184, 188, 19, 109, 199, 17, 7, 236, 102, 116, 241, 20, 30, 234, 168, 194, 1, 45, 26, 142, 181, 43, 241, 160, 50, 192, 223, 106, 37, 123, 199, 67, 237, 16, 1, 210, 244, 203, 211, 66, 158, 228, 62, 92, 0, 4, 33, 82, 167, 104, 200, 246, 0, 5, 197, 232, 111, 177, 58, 222, 0, 26, 71, 218, 69, 48, 0, 230, 133, 241, 13, 123, 128, 11, 129, 178, 221, 238, 43, 185, 52, 128, 13, 11, 77, 187, 162, 236, 95, 89, 32, 3, 47, 66, 71, 194, 17, 0, 32, 94, 165, 189, 23, 163, 39, 176, 0, 69, 237, 115, 163, 102, 147, 69, 121, 172, 216, 1, 141, 65, 45, 196, 82, 222, 0, 92, 232, 54, 200, 138, 91, 192, 5, 232, 87, 150, 251, 17, 75, 120, 1, 2, 25, 239, 25, 36, 183, 0, 0, 155, 40, 96, 0, 13, 218, 96, 0, 7, 255, 217, 10} 147 | 148 | _, err := Transform(largeImage, Options{Width: 1}) 149 | if err == nil { 150 | t.Errorf("Transform with large image did not return expected error") 151 | } 152 | } 153 | 154 | func TestTransform_InvalidFormat(t *testing.T) { 155 | src := newImage(2, 2, red, green, blue, yellow) 156 | buf := new(bytes.Buffer) 157 | if err := png.Encode(buf, src); err != nil { 158 | t.Errorf("error encoding reference image: %v", err) 159 | } 160 | 161 | _, err := Transform(buf.Bytes(), Options{Format: "invalid"}) 162 | if err == nil { 163 | t.Errorf("Transform with invalid format did not return expected error") 164 | } 165 | } 166 | 167 | // Test that each of the eight EXIF orientations is applied to the transformed 168 | // image appropriately. 169 | func TestTransform_EXIF(t *testing.T) { 170 | ref := newImage(2, 2, red, green, blue, yellow) 171 | 172 | // reference image encoded as TIF, with each of the 8 EXIF orientations 173 | // applied in reverse and the EXIF tag set. When orientation is 174 | // applied, each should display as the ref image. 175 | tests := []string{ 176 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAAAQAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGQAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPrPwPAfDBn+////n+E/IAAA//9DzAj4AA==", // Orientation=1 177 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAAAgAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGQAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGL4z/D/PwPD////GcAUIAAA//9HyAj4AA==", // Orientation=2 178 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAAAwAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFwAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPr/n+E/AwOY/A9iAAIAAP//T8AI+AA=", // Orientation=3 179 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABAAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGgAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGJg+P///3+G//8ZGP6DICAAAP//S8QI+A==", // Orientation=4 180 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABQAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGAAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPrPwABC/xn+M/wHkYAAAAD//0PMCPg=", // Orientation=5 181 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABgAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAGAAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGL4z/D/PwgzMIDQf0AAAAD//0vECPg=", // Orientation=6 182 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABwAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFgAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPr/nwECGf7/BxGAAAAA//9PwAj4", // Orientation=7 183 | "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAACAAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFQAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nGJg+P//P4QAQ0AAAAD//0fICPgA", // Orientation=8 184 | } 185 | 186 | for _, src := range tests { 187 | in, err := base64.StdEncoding.DecodeString(src) 188 | if err != nil { 189 | t.Errorf("error decoding source: %v", err) 190 | } 191 | out, err := Transform(in, Options{Height: -1, Width: -1, Format: "tiff"}) 192 | if err != nil { 193 | t.Errorf("Transform(%q) returned error: %v", src, err) 194 | } 195 | d, _, err := image.Decode(bytes.NewReader(out)) 196 | if err != nil { 197 | t.Errorf("error decoding transformed image: %v", err) 198 | } 199 | 200 | // construct new image with same colors as decoded image for easy comparison 201 | got := newImage(2, 2, d.At(0, 0), d.At(1, 0), d.At(0, 1), d.At(1, 1)) 202 | if want := ref; !reflect.DeepEqual(got, want) { 203 | t.Errorf("Transform(%v) returned image %#v, want %#v", src, got, want) 204 | } 205 | } 206 | } 207 | 208 | // Test that EXIF orientation and any additional transforms don't conflict. 209 | // This is tested with orientation=7, which involves both a rotation and a 210 | // flip, combined with an additional rotation transform. 211 | func TestTransform_EXIF_Rotate(t *testing.T) { 212 | // base64-encoded TIF image (2x2 yellow green blue red) with EXIF 213 | // orientation=7. When orientation applied, displays as (2x2 red green 214 | // blue yellow). 215 | src := "SUkqAAgAAAAOAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwAEAAAAtgAAAAMBAwABAAAACAAAAAYBAwABAAAAAgAAABEBBAABAAAAzgAAABIBAwABAAAABwAAABUBAwABAAAABAAAABYBAwABAAAAAgAAABcBBAABAAAAFgAAABoBBQABAAAAvgAAABsBBQABAAAAxgAAACgBAwABAAAAAgAAAFIBAwABAAAAAgAAAAAAAAAIAAgACAAIAEgAAAABAAAASAAAAAEAAAB4nPr/nwECGf7/BxGAAAAA//9PwAj4" 216 | 217 | in, err := base64.StdEncoding.DecodeString(src) 218 | if err != nil { 219 | t.Errorf("error decoding source: %v", err) 220 | } 221 | out, err := Transform(in, Options{Rotate: 90, Format: "tiff"}) 222 | if err != nil { 223 | t.Errorf("Transform(%q) returned error: %v", src, err) 224 | } 225 | d, _, err := image.Decode(bytes.NewReader(out)) 226 | if err != nil { 227 | t.Errorf("error decoding transformed image: %v", err) 228 | } 229 | 230 | // construct new image with same colors as decoded image for easy comparison 231 | got := newImage(2, 2, d.At(0, 0), d.At(1, 0), d.At(0, 1), d.At(1, 1)) 232 | want := newImage(2, 2, green, yellow, red, blue) 233 | if !reflect.DeepEqual(got, want) { 234 | t.Errorf("Transform(%v) returned image %#v, want %#v", src, got, want) 235 | } 236 | } 237 | 238 | func TestTransformImage(t *testing.T) { 239 | // ref is a 2x2 reference image containing four colors 240 | ref := newImage(2, 2, red, green, blue, yellow) 241 | 242 | // cropRef is a 4x4 image with four colors, each in 2x2 quarter 243 | cropRef := newImage(4, 4, red, red, green, green, red, red, green, green, blue, blue, yellow, yellow, blue, blue, yellow, yellow) 244 | 245 | // use simpler filter while testing that won't skew colors 246 | resampleFilter = imaging.Box 247 | 248 | tests := []struct { 249 | src image.Image // source image to transform 250 | opt Options // options to apply during transform 251 | want image.Image // expected transformed image 252 | }{ 253 | // no transformation 254 | {ref, emptyOptions, ref}, 255 | 256 | // rotations 257 | {ref, Options{Rotate: 45}, ref}, // invalid rotation is a noop 258 | {ref, Options{Rotate: 360}, ref}, 259 | {ref, Options{Rotate: 90}, newImage(2, 2, green, yellow, red, blue)}, 260 | {ref, Options{Rotate: 180}, newImage(2, 2, yellow, blue, green, red)}, 261 | {ref, Options{Rotate: 270}, newImage(2, 2, blue, red, yellow, green)}, 262 | {ref, Options{Rotate: 630}, newImage(2, 2, blue, red, yellow, green)}, 263 | {ref, Options{Rotate: -90}, newImage(2, 2, blue, red, yellow, green)}, 264 | 265 | // flips 266 | { 267 | ref, 268 | Options{FlipHorizontal: true}, 269 | newImage(2, 2, green, red, yellow, blue), 270 | }, 271 | { 272 | ref, 273 | Options{FlipVertical: true}, 274 | newImage(2, 2, blue, yellow, red, green), 275 | }, 276 | { 277 | ref, 278 | Options{FlipHorizontal: true, FlipVertical: true}, 279 | newImage(2, 2, yellow, blue, green, red), 280 | }, 281 | { 282 | ref, 283 | Options{Rotate: 90, FlipHorizontal: true}, 284 | newImage(2, 2, yellow, green, blue, red), 285 | }, 286 | 287 | // resizing 288 | { // can't resize larger than original image 289 | ref, 290 | Options{Width: 100, Height: 100}, 291 | ref, 292 | }, 293 | { // can resize larger than original image 294 | ref, 295 | Options{Width: 4, Height: 4, ScaleUp: true}, 296 | newImage(4, 4, red, red, green, green, red, red, green, green, blue, blue, yellow, yellow, blue, blue, yellow, yellow), 297 | }, 298 | { // invalid values 299 | ref, 300 | Options{Width: -1, Height: -1}, 301 | ref, 302 | }, 303 | { // absolute values 304 | newImage(100, 100, red), 305 | Options{Width: 1, Height: 1}, 306 | newImage(1, 1, red), 307 | }, 308 | { // percentage values 309 | newImage(100, 100, red), 310 | Options{Width: 0.50, Height: 0.25}, 311 | newImage(50, 25, red), 312 | }, 313 | { // only width specified, proportional height 314 | newImage(100, 50, red), 315 | Options{Width: 50}, 316 | newImage(50, 25, red), 317 | }, 318 | { // only height specified, proportional width 319 | newImage(100, 50, red), 320 | Options{Height: 25}, 321 | newImage(50, 25, red), 322 | }, 323 | { // resize in one dimenstion, with cropping 324 | newImage(4, 2, red, red, blue, blue, red, red, blue, blue), 325 | Options{Width: 4, Height: 1}, 326 | newImage(4, 1, red, red, blue, blue), 327 | }, 328 | { // resize in two dimensions, with cropping 329 | newImage(4, 2, red, red, blue, blue, red, red, blue, blue), 330 | Options{Width: 2, Height: 2}, 331 | newImage(2, 2, red, blue, red, blue), 332 | }, 333 | { // resize in two dimensions, fit option prevents cropping 334 | newImage(4, 2, red, red, blue, blue, red, red, blue, blue), 335 | Options{Width: 2, Height: 2, Fit: true}, 336 | newImage(2, 1, red, blue), 337 | }, 338 | { // scale image explicitly 339 | newImage(4, 2, red, red, blue, blue, red, red, blue, blue), 340 | Options{Width: 2, Height: 1}, 341 | newImage(2, 1, red, blue), 342 | }, 343 | 344 | // combinations of options 345 | { 346 | newImage(4, 2, red, red, blue, blue, red, red, blue, blue), 347 | Options{Width: 2, Height: 1, Fit: true, FlipHorizontal: true, Rotate: 90}, 348 | newImage(1, 2, blue, red), 349 | }, 350 | 351 | // crop 352 | { // quarter ((0, 0), (2, 2)) -> red 353 | cropRef, 354 | Options{CropHeight: 2, CropWidth: 2}, 355 | newImage(2, 2, red, red, red, red), 356 | }, 357 | { // quarter ((2, 0), (4, 2)) -> green 358 | cropRef, 359 | Options{CropHeight: 2, CropWidth: 2, CropX: 2}, 360 | newImage(2, 2, green, green, green, green), 361 | }, 362 | { // quarter ((0, 2), (2, 4)) -> blue 363 | cropRef, 364 | Options{CropHeight: 2, CropWidth: 2, CropX: 0, CropY: 2}, 365 | newImage(2, 2, blue, blue, blue, blue), 366 | }, 367 | { // quarter ((2, 2), (4, 4)) -> yellow 368 | cropRef, 369 | Options{CropHeight: 2, CropWidth: 2, CropX: 2, CropY: 2}, 370 | newImage(2, 2, yellow, yellow, yellow, yellow), 371 | }, 372 | 373 | // percentage-based resize in addition to rectangular crop 374 | { 375 | newImage(12, 12, red), 376 | Options{Width: 0.5, Height: 0.5, CropWidth: 8, CropHeight: 8}, 377 | newImage(6, 6, red), 378 | }, 379 | } 380 | 381 | for _, tt := range tests { 382 | if got := transformImage(tt.src, tt.opt); !reflect.DeepEqual(got, tt.want) { 383 | t.Errorf("transformImage(%v, %v) returned image %#v, want %#v", tt.src, tt.opt, got, tt.want) 384 | } 385 | } 386 | } 387 | --------------------------------------------------------------------------------