├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .godir ├── .golangci.toml ├── .travis.yml ├── Dockerfile ├── History.md ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── benchmark.sh ├── controllers.go ├── docker-compose.yml ├── error.go ├── error_test.go ├── go.mod ├── go.sum ├── health.go ├── health_test.go ├── image.go ├── image_test.go ├── imaginary.go ├── log.go ├── log_test.go ├── middleware.go ├── options.go ├── options_test.go ├── params.go ├── params_test.go ├── placeholder.go ├── server.go ├── server_test.go ├── source.go ├── source_body.go ├── source_body_test.go ├── source_fs.go ├── source_fs_test.go ├── source_http.go ├── source_http_test.go ├── source_test.go ├── testdata ├── 1024bytes ├── flyio-button.svg ├── imaginary.jpg ├── large.jpg ├── medium.jpg ├── server.crt ├── server.key ├── smart-crop.jpg ├── test.png └── test.webp ├── type.go ├── type_test.go └── version.go /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/go/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.16, 1.17, 1-bullseye, 1.16-bullseye, 1.17-bullseye, 1-buster, 1.16-buster, 1.17-buster 4 | ARG VARIANT="1.17-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT} 6 | 7 | # Versions of libvips and golanci-lint 8 | ARG LIBVIPS_VERSION=8.12.2 9 | ARG GOLANGCILINT_VERSION=1.29.0 10 | 11 | # Install additional OS packages 12 | RUN DEBIAN_FRONTEND=noninteractive \ 13 | apt-get update && \ 14 | apt-get install --no-install-recommends -y \ 15 | ca-certificates \ 16 | automake build-essential curl \ 17 | procps libopenexr25 libmagickwand-6.q16-6 libpango1.0-0 libmatio11 \ 18 | libopenslide0 libjemalloc2 gobject-introspection gtk-doc-tools \ 19 | libglib2.0-0 libglib2.0-dev libjpeg62-turbo libjpeg62-turbo-dev \ 20 | libpng16-16 libpng-dev libwebp6 libwebpmux3 libwebpdemux2 libwebp-dev \ 21 | libtiff5 libtiff5-dev libgif7 libgif-dev libexif12 libexif-dev \ 22 | libxml2 libxml2-dev libpoppler-glib8 libpoppler-glib-dev \ 23 | swig libmagickwand-dev libpango1.0-dev libmatio-dev libopenslide-dev \ 24 | libcfitsio9 libcfitsio-dev libgsf-1-114 libgsf-1-dev fftw3 fftw3-dev \ 25 | liborc-0.4-0 liborc-0.4-dev librsvg2-2 librsvg2-dev libimagequant0 \ 26 | libimagequant-dev libheif1 libheif-dev && \ 27 | cd /tmp && \ 28 | curl -fsSLO https://github.com/libvips/libvips/releases/download/v${LIBVIPS_VERSION}/vips-${LIBVIPS_VERSION}.tar.gz && \ 29 | tar zvxf vips-${LIBVIPS_VERSION}.tar.gz && \ 30 | cd /tmp/vips-${LIBVIPS_VERSION} && \ 31 | CFLAGS="-g -O3" CXXFLAGS="-D_GLIBCXX_USE_CXX11_ABI=0 -g -O3" \ 32 | ./configure \ 33 | --disable-debug \ 34 | --disable-dependency-tracking \ 35 | --disable-introspection \ 36 | --disable-static \ 37 | --enable-gtk-doc-html=no \ 38 | --enable-gtk-doc=no \ 39 | --enable-pyvips8=no && \ 40 | make && \ 41 | make install && \ 42 | ldconfig 43 | 44 | # Installing golangci-lint 45 | RUN curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "${GOPATH}/bin" v${GOLANGCILINT_VERSION} 46 | 47 | # [Optional] Uncomment the next lines to use go get to install anything else you need 48 | # USER vscode 49 | # RUN go get -x 50 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/go 3 | { 4 | "name": "Go", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.17 9 | // Append -bullseye or -buster to pin to an OS version. 10 | // Use -bullseye variants on local arm64/Apple Silicon. 11 | "VARIANT": "1.17-bullseye" 12 | } 13 | }, 14 | "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 15 | 16 | // Set *default* container specific settings.json values on container create. 17 | "settings": { 18 | "go.toolsManagement.checkForUpdates": "local", 19 | "go.useLanguageServer": true, 20 | "go.gopath": "/go", 21 | "go.goroot": "/usr/local/go" 22 | }, 23 | 24 | // Add the IDs of extensions you want installed when the container is created. 25 | "extensions": [ 26 | "golang.Go" 27 | ], 28 | 29 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 30 | "forwardPorts": [9000], 31 | 32 | // Use 'postCreateCommand' to run commands after the container is created. 33 | // "postCreateCommand": "go version", 34 | 35 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 36 | "remoteUser": "vscode", 37 | "features": { 38 | "docker-from-docker": "latest" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*~ 2 | .devcontainer 3 | .github 4 | .git 5 | Dockerfile 6 | docker-compose.yml 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.go] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [Makefile] 12 | charset = utf-8 13 | indent_style = tab 14 | indent_size = 2 15 | end_of_line = lf 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.yml] 20 | indent_style = space 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | open_collective: imaginary 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | .DS_Store 6 | Thumbs.db 7 | 8 | # Folders 9 | _obj 10 | _test 11 | .idea 12 | 13 | # Architecture specific extensions/prefixes 14 | *.[568vq] 15 | [568vq].out 16 | 17 | *.cgo1.go 18 | *.cgo2.c 19 | _cgo_defun.c 20 | _cgo_gotypes.go 21 | _cgo_export.* 22 | 23 | _testmain.go 24 | 25 | /dist 26 | *.exe 27 | *.test 28 | bin/ 29 | .vagrant/ 30 | 31 | *.old 32 | *.attr 33 | *.swp 34 | 35 | imaginary 36 | bin/imaginary 37 | -------------------------------------------------------------------------------- /.godir: -------------------------------------------------------------------------------- 1 | imaginary 2 | -------------------------------------------------------------------------------- /.golangci.toml: -------------------------------------------------------------------------------- 1 | [run] 2 | concurrency = 4 3 | tests = false 4 | 5 | [linters-settings] 6 | [linters-settings.gocyclo] 7 | min-complexity = 20 8 | 9 | [linters-settings.goconst] 10 | min-len = 2 11 | min-occurrences = 2 12 | 13 | [linters-settings.misspell] 14 | locale = "US" 15 | 16 | [linters] 17 | # White-listing, to be more CI safe. 18 | disable-all = true 19 | 20 | # @see https://github.com/golangci/golangci-lint#enabled-by-default-linters 21 | enable = [ 22 | "staticcheck", 23 | "gosimple", 24 | "ineffassign", 25 | "typecheck", 26 | "govet", 27 | # "errcheck", 28 | "unused", 29 | "structcheck", 30 | "varcheck", 31 | "deadcode", 32 | 33 | "stylecheck", 34 | "gosec", 35 | "interfacer", 36 | "unconvert", 37 | # "goconst", 38 | "gocyclo", 39 | # "maligned", 40 | "depguard", 41 | "misspell", 42 | "unparam", 43 | "scopelint", # Would like to ignore *_test.go files, but can't atm. 44 | "gocritic", 45 | ] 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | stages: 4 | - test 5 | - deploy 6 | 7 | services: 8 | - docker 9 | 10 | dist: focal 11 | 12 | go: 13 | - "1.13" 14 | - "1.14" 15 | 16 | env: 17 | global: 18 | - GOLANG_VERSION="${TRAVIS_GO_VERSION}" 19 | - IMAGINARY_VERSION="${TRAVIS_TAG:-dev}" 20 | matrix: 21 | - LIBVIPS=8.8.4 22 | - LIBVIPS=8.9.2 23 | - LIBVIPS=8.10.0 24 | 25 | before_install: 26 | - docker pull h2non/imaginary:latest || true 27 | 28 | install: 29 | - "true" 30 | 31 | script: 32 | - docker build --pull --cache-from h2non/imaginary:latest --build-arg GOLANG_VERSION="${GOLANG_VERSION%.x}" --build-arg LIBVIPS_VERSION="${LIBVIPS}" --build-arg IMAGINARY_VERSION="${IMAGINARY_VERSION#v}" --tag h2non/imaginary:${IMAGINARY_VERSION#v} . 33 | 34 | # jobs: 35 | # include: 36 | # # Deploy stage 37 | # - stage: deploy 38 | # script: 39 | # - docker login -u "$DOCKER_LOGIN" -p "$DOCKER_PWD" 40 | # - docker tag h2non/imaginary:${IMAGINARY_VERSION#v} h2non/imaginary:latest 41 | # - docker push h2non/imaginary:${IMAGINARY_VERSION#v} 42 | # - docker push h2non/imaginary:latest 43 | # if: "${TRAVIS_TAG} =~ ^v([0-9]+).([0-9]+).([0-9]+)$" 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOLANG_VERSION=1.17 2 | FROM golang:${GOLANG_VERSION}-bullseye as builder 3 | 4 | ARG IMAGINARY_VERSION=dev 5 | ARG LIBVIPS_VERSION=8.12.2 6 | ARG GOLANGCILINT_VERSION=1.29.0 7 | 8 | # Installs libvips + required libraries 9 | RUN DEBIAN_FRONTEND=noninteractive \ 10 | apt-get update && \ 11 | apt-get install --no-install-recommends -y \ 12 | ca-certificates \ 13 | automake build-essential curl \ 14 | gobject-introspection gtk-doc-tools libglib2.0-dev libjpeg62-turbo-dev libpng-dev \ 15 | libwebp-dev libtiff5-dev libgif-dev libexif-dev libxml2-dev libpoppler-glib-dev \ 16 | swig libmagickwand-dev libpango1.0-dev libmatio-dev libopenslide-dev libcfitsio-dev \ 17 | libgsf-1-dev fftw3-dev liborc-0.4-dev librsvg2-dev libimagequant-dev libheif-dev && \ 18 | cd /tmp && \ 19 | curl -fsSLO https://github.com/libvips/libvips/releases/download/v${LIBVIPS_VERSION}/vips-${LIBVIPS_VERSION}.tar.gz && \ 20 | tar zvxf vips-${LIBVIPS_VERSION}.tar.gz && \ 21 | cd /tmp/vips-${LIBVIPS_VERSION} && \ 22 | CFLAGS="-g -O3" CXXFLAGS="-D_GLIBCXX_USE_CXX11_ABI=0 -g -O3" \ 23 | ./configure \ 24 | --disable-debug \ 25 | --disable-dependency-tracking \ 26 | --disable-introspection \ 27 | --disable-static \ 28 | --enable-gtk-doc-html=no \ 29 | --enable-gtk-doc=no \ 30 | --enable-pyvips8=no && \ 31 | make && \ 32 | make install && \ 33 | ldconfig 34 | 35 | # Installing golangci-lint 36 | WORKDIR /tmp 37 | RUN curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "${GOPATH}/bin" v${GOLANGCILINT_VERSION} 38 | 39 | WORKDIR ${GOPATH}/src/github.com/h2non/imaginary 40 | 41 | # Cache go modules 42 | ENV GO111MODULE=on 43 | 44 | COPY go.mod . 45 | COPY go.sum . 46 | 47 | RUN go mod download 48 | 49 | # Copy imaginary sources 50 | COPY . . 51 | 52 | # Run quality control 53 | RUN go test ./... -test.v -race -test.coverprofile=atomic . 54 | RUN golangci-lint run . 55 | 56 | # Compile imaginary 57 | RUN go build -a \ 58 | -o ${GOPATH}/bin/imaginary \ 59 | -ldflags="-s -w -h -X main.Version=${IMAGINARY_VERSION}" \ 60 | github.com/h2non/imaginary 61 | 62 | FROM debian:bullseye-slim 63 | 64 | ARG IMAGINARY_VERSION 65 | 66 | LABEL maintainer="tomas@aparicio.me" \ 67 | org.label-schema.description="Fast, simple, scalable HTTP microservice for high-level image processing with first-class Docker support" \ 68 | org.label-schema.schema-version="1.0" \ 69 | org.label-schema.url="https://github.com/h2non/imaginary" \ 70 | org.label-schema.vcs-url="https://github.com/h2non/imaginary" \ 71 | org.label-schema.version="${IMAGINARY_VERSION}" 72 | 73 | COPY --from=builder /usr/local/lib /usr/local/lib 74 | COPY --from=builder /go/bin/imaginary /usr/local/bin/imaginary 75 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 76 | 77 | # Install runtime dependencies 78 | RUN DEBIAN_FRONTEND=noninteractive \ 79 | apt-get update && \ 80 | apt-get install --no-install-recommends -y \ 81 | procps libglib2.0-0 libjpeg62-turbo libpng16-16 libopenexr25 \ 82 | libwebp6 libwebpmux3 libwebpdemux2 libtiff5 libgif7 libexif12 libxml2 libpoppler-glib8 \ 83 | libmagickwand-6.q16-6 libpango1.0-0 libmatio11 libopenslide0 libjemalloc2 \ 84 | libgsf-1-114 fftw3 liborc-0.4-0 librsvg2-2 libcfitsio9 libimagequant0 libheif1 && \ 85 | ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ 86 | apt-get autoremove -y && \ 87 | apt-get autoclean && \ 88 | apt-get clean && \ 89 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 90 | ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so 91 | 92 | # Server port to listen 93 | ENV PORT 9000 94 | 95 | # Drop privileges for non-UID mapped environments 96 | USER nobody 97 | 98 | # Run the entrypoint command by default when the container starts. 99 | ENTRYPOINT ["/usr/local/bin/imaginary"] 100 | 101 | # Expose the server TCP port 102 | EXPOSE ${PORT} 103 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 1.2.4 / 2020-08-12 3 | ================== 4 | 5 | * upgrade: libvips to v8.10.0 6 | * fix(pipeline): add missing autorate (#326) 7 | 8 | v1.2.3 / 2020-08-04 9 | =================== 10 | 11 | * feat(#315, #309): autorotate / gracefully fallback failed image type encoding 12 | 13 | v1.2.2 / 2020-06-11 14 | =================== 15 | 16 | * fix(docs): define mirror as default extend param 17 | * refactor(params): use mirror as default extend behavior 18 | * refactor(params): use mirror as default extend behavior 19 | 20 | v1.2.1 / 2020-06-08 21 | =================== 22 | 23 | * fix(history): release changes 24 | * Merge branch 'master' of https://github.com/h2non/imaginary 25 | * feat(version): release v1.1.1 26 | * [improvement/server-graceful-shutdown] Add support of graceful shutdown (#312) 27 | * fix 28 | 29 | v1.2.0 / 2020-06-07 30 | =================== 31 | 32 | * feat(ci): use job stages 33 | * feat(ci): use job stages 34 | * feat(ci): use job stages 35 | * fix(ci): deploy filter 36 | * feat(ci): only deploy for libvips 8.9.2 37 | * fix(ci): strip v in semver version value 38 | * feat(History): add changes 39 | * New release, minor features, bimg upgrade and several fixes (#311) 40 | * watermarkImage must be in lowerCamelCase (#255) 41 | * Pre-release HEIF / HEIC support (#297) 42 | * [improvement/log-levels] Add support set log levels (#301) 43 | * chore(license): update year 44 | * chore(docs): delete not valid contributor 45 | * Added PlaceholderStatus option (#304) 46 | * Create FUNDING.yml 47 | * Delete README.md~ 48 | * Add fly.io (#300) 49 | 50 | v1.1.3 / 2020-02-28 51 | =================== 52 | 53 | * feat: add history changes 54 | * Merge branch 'master' of https://github.com/h2non/imaginary 55 | * refactor 56 | * add fluentd config example to ingest imaginary logs (#260) 57 | 58 | v1.1.2 / 2020-02-08 59 | =================== 60 | 61 | * feature: implement interlace parameter (#273) 62 | * Implement wildcard for paths for the allowed-origins option (#290) 63 | * Go Modules, Code Refactoring, VIPS 8.8.1, etc. (#269) 64 | * feature: implement aspect ratio (#275) 65 | * Fix and add test for 2 buckets example (#281) 66 | * Fix "--allowed-origins" type (#282) 67 | * fix(docs): watermarkimage -> watermarkImage 68 | * refactor(docs): removen image layers badge 69 | * refactor: version set dev 70 | 71 | v1.1.1 / 2019-07-07 72 | =================== 73 | 74 | * feat(version): bump patch 75 | * add validation to allowed-origins to include path (#265) 76 | * Width and height are required in thumbnail request (#262) 77 | * Merge pull request #258 from r-antonio/master 78 | * Cleaned code to check for existence of headers 79 | * Modified check for empty or undefined headers 80 | * Merge pull request #259 from h2non/release/next 81 | * Code Style changes 82 | * Removing gometalinter in favor of golangci-lint 83 | * Bumping libvips versions for building 84 | * Merge branch 'master' into release/next 85 | * Changed custom headers naming to forward headers 86 | * Fixed spacing typo and headers checking 87 | * Changed forwarded headers order, added tests and some fixes 88 | * Added custom headers forwarding support 89 | * Merge pull request #254 from nicolasmure/fix/readme 90 | * apply @Dynom patch to fix cli help 91 | * Update README.md 92 | * fix enable-url-source param description in README 93 | * Merge branch 'NextWithCIBase' into release/next 94 | * Reverting and reordering 95 | * ups 96 | * Moving back to a single file, worst case we need to maintain two again. 97 | * fixing a var 98 | * Updating travis config, adding docker build and preparing for automated image building 99 | * Megacheck has been removed in favor of staticcheck 100 | * timing the pull separately, by putting it in a before_install 101 | * Trying with a dev base image 102 | * Adding .dockerignore and consistently guarding the variables 103 | * Merging in changes by jbergstroem with some extra changes 104 | * Merge pull request #229 from Dynom/uniformBuildRefactoring 105 | * Improving gometalinter config 106 | * First travis-ci config attempt 107 | * Making sure vendor is not stale and that our deps are correctly configured 108 | * gometalinter config 109 | * Fixing Gopkg.toml 110 | * Adding a newish Dockerfile 111 | 112 | v1.1.0 / 2019-02-21 113 | =================== 114 | 115 | * bumping to 1.1.0 116 | * Merge pull request #243 from Dynom/CheckingIfDefaultValueWasSpecified 117 | * Updating the documentation 118 | * Testing if an ImageOption false value, was in fact requested 119 | 120 | v1.0.18 / 2019-01-28 121 | ==================== 122 | 123 | * Bumping version to 1.0.18 124 | * Isolated the calculation added a test and added Rounding (#242) 125 | 126 | v1.0.17 / 2019-01-20 127 | ==================== 128 | 129 | * Bumping version to 1.0.17 130 | * Merge pull request #239 from Dynom/RefactoringParameterParsingToImproveFeedback 131 | * cleanup 132 | * Bumping Go's version requirement 133 | * Allow Go 1.9 to fail 134 | * Refactoring, making things simpler 135 | * Merge pull request #230 from Dynom/NonFunctionalImprovements 136 | * minor styling 137 | * Simplifying some if statements, removing unnecessary parenthesis and added an explicit error ignore 138 | * Correct casing for multiple words 139 | * explicitly ignoring errors 140 | * ErrorReply's return value was never used. 141 | * Changing the if's to a single map lookup. 142 | * More literal to constant replacements 143 | * Correcting comments, casing and constant use 144 | * Removed unused variable and explicitly ignoring errors 145 | * Correcting the use of abbreviations 146 | * Comment fix 147 | * Removing literals in favor of http constants 148 | * Unavailable is not actually used, replaced it with _ to better convey intent. 149 | * Removing unused function 150 | * Style fixes 151 | * Merge pull request #227 from Dynom/moarHealthEndpointDetails 152 | * Merge pull request #228 from Dynom/addingExtraOriginTest 153 | * Exposing several extra details 154 | * Including a test case with multiple subdomains 155 | * docs(watermarkimage): Add docs for the /watermarkimage endpoint (#226) 156 | * refactor(README): remove header image 157 | 158 | v1.0.16 / 2018-12-11 159 | ==================== 160 | 161 | * Adding LIBVIPS 8.7 and updating libvips URL (#225) 162 | 163 | v1.0.15 / 2018-12-10 164 | ==================== 165 | 166 | * Updating bimg, libvips and Go 167 | * Updated dockerfile vips repo (#222) 168 | * fix: correct fit operation with autorotated images by switching width/height in certain cases (#208) 169 | * Watermark image api (#221) 170 | * Adding remote url wildcard support (#219) 171 | * Bump Go versions and use '.x' to always get latest patch versions (#220) 172 | * Update README.md (#207) 173 | * Fix typo in documentation (#202) 174 | * Drop salt as suggested in #194 (#200) 175 | * Add URL signature feature (#194) 176 | * fix(docker): remove race detector (#197) 177 | * feat(version): bump to v1.0.15 178 | * Changing build steps (#189) 179 | 180 | v1.0.14 / 2018-03-05 181 | ==================== 182 | 183 | * feat(version): bump to v1.0.14 184 | * Add Docker Compose note to README (#174) 185 | * Fixes https by installing root CA certificates (#186) 186 | 187 | v1.0.13 / 2018-03-01 188 | ==================== 189 | 190 | * fix(Dockerfile): update version sha2 hash 191 | * feat(history): update changelog 192 | * feat(version): bump to v1.0.13 193 | * feat(Docker): upgrade libvips to v8.6.2 (#184) 194 | * feat(vendor): upgrade bimg to v1.0.18 195 | * fix(debug): implement custom debug function 196 | * feat: add docker-compose.yml 197 | * Merge branch 'master' of https://github.com/h2non/imaginary 198 | * refactor(vendor): remove go-debug package from vendor 199 | * refactor(docs): remove codesponsor :( 200 | * fix testdata image links (#173) 201 | * Log hours in 24 hour clock (#165) 202 | * refactor(docs): update CLI usage and minimum requirements 203 | 204 | v1.0.11 / 2017-11-14 205 | ==================== 206 | 207 | * fix(type_test): use string for proper formatting 208 | * feat(version): bump to v1.0.11 209 | * feat(bimg): update to v1.0.17 210 | * Merge branch 'realla-add-fit' 211 | * merge(add-fit): fix conflicts in server_test.go 212 | * refactor(image): remove else statement 213 | * fix(test): remove unused variable body 214 | * Add type=auto using client Accept header to auto negotiate type. (#162) 215 | * Add /fit action 216 | 217 | v1.0.10 / 2017-10-30 218 | ==================== 219 | 220 | * feat(docs): update CLI usage help 221 | * feat(#156): support disable endpoints (#160) 222 | 223 | v1.0.9 / 2017-10-29 224 | =================== 225 | 226 | * feat(version): bump to v1.0.9 227 | * fix(#157): disable gzip compression support 228 | * debug(travis) 229 | * debug(travis) 230 | * debug(travis) 231 | * debug(travis) 232 | * debug(travis) 233 | * refactor(Dockerfile): use local source copy 234 | * refactor(requirements): add Go 1.6+ as minimum requirement 235 | * feat(vendor): support dependencies vendoring 236 | * refactor(Gopkg): define version 237 | * feat(vendor): add vendor dependencies 238 | * feat(travis): add Go 1.9 support 239 | * refactor(docs): specify POST payloads in description 240 | * feat(docs): add imagelayer badge 241 | * feat(docs): add imagelayer badge 242 | * feat(docs): add imagelayer badge 243 | * feat(docs): add imagelayer badge 244 | 245 | v1.0.8 / 2017-10-06 246 | =================== 247 | 248 | * feat(#101): add pipeline endpoint implementation + smart crop (#154) 249 | * refactor(docs): move sponsor banner 250 | * feat(docs): add sponsor ad 251 | * refactor(license): update copyright 252 | 253 | v1.0.7 / 2017-09-11 254 | =================== 255 | 256 | * feat(version): bump to v1.0.7 257 | * feat(version): bump to v1.0.6 258 | 259 | v1.0.5 / 2017-09-10 260 | =================== 261 | 262 | * feat(version): bump to v1.0.5 263 | * feat(History): update version changes 264 | * feat(params): add stripmeta params 265 | 266 | v1.0.4 / 2017-08-21 267 | =================== 268 | 269 | * feat(version): bump to 1.0.4 270 | * Mapping Blur URL params to the ImageOptions struct fields (#152) 271 | 272 | v1.0.3 / 2017-08-20 273 | =================== 274 | 275 | * feat(version): bump to v1.0.3 276 | * Merge branch 'master' of https://github.com/h2non/imaginary 277 | * fix(docs): CLI spec typo 278 | * Adding the Gaussian Blur feature plus a few minor formatting with gofmt. (#150) 279 | * feat(docs): update maintainer note 280 | 281 | v1.0.2 / 2017-07-28 282 | =================== 283 | 284 | * feat(version): bump to v1.0.2 285 | * fix(#146): handle proper response code range for max allowed size 286 | * Typos and minor language in help text (#144) 287 | * Update README.md (#143) 288 | * feat(History): add missing Docker changes 289 | * fix(server_test): assert content type header is present 290 | * fix(Docker): use proper SHA256 hash 291 | * feat(Docker): upgrade Go to v1.8.3 and libvips to v8.5.6 292 | * feat(changelog): update v1.0.1 changes 293 | * feat(version): bump to v1.0.1 294 | * feat(#140): expose Content-Length header 295 | 296 | v1.0.0 / 2017-05-27 297 | =================== 298 | 299 | * refactor(controller): add height for smart crop form 300 | * feat(controllers): add smart crop form 301 | * feat(version): bump to v1.0.0 302 | * feat(History): update changes 303 | * Supporting smart crop (#136) 304 | 305 | v0.1.31 / 2017-05-18 306 | ==================== 307 | 308 | * feat(History): update latest changes 309 | * feat(version): bump to 0.1.31 310 | * feat(Dockerfile): use libvips v8.5.5, Go v1.8.1 and bimg v1.0.8 311 | * Correcting the documentation, caching headers are always sent, regardless of being fetched from mount or by URL. (#133) 312 | * fix(docs): move toc top level sections 313 | * feat(docs): add new maintainer notice (thanks to @kirillDanshin) 314 | * feat(travis): use Go 1.8 315 | * refactor(docs): update support badges 316 | * feat(docs): add maintainers section 317 | * fix(.godir): add project name 318 | * fix(#124): fast workaround to unblock Heroku deployment until the buildpack can be updated 319 | * Deploy on Cloud Foundry PaaS (#122) 320 | * Add backers & sponsors from open collective (#119) 321 | * 1. remove the .godir as Heroku and Cloud Foundry remove the support. (#117) 322 | 323 | v0.1.30 / 2017-01-18 324 | ==================== 325 | 326 | * refacgor(version): add comments 327 | * feat(version): bump to v0.1.30 328 | * feat(History): update changes 329 | * fix(travis): remove libvips 8.5 330 | * feat(travis): add multi libvips testing environments 331 | * fix(travis): use proper preinstall.sh URL 332 | * Update .travis.yml 333 | * fix(tests): integration with bimg v1.0.7 334 | * fix(type): bimg v1.0.7 integration 335 | * fix(type): bimg v1.0.7 integration 336 | * Update History.md 337 | 338 | v0.1.29 / 2016-12-18 339 | ==================== 340 | 341 | * feat(version): bump to 0.1.29 342 | * feat(max-allowed-size): add new option max-allowed-size in bytes (#111) 343 | * Merge pull request #112 from touhonoob/fix-help-allowed-origins 344 | * fix(usage): correct help message of 'allowed-origins' 345 | * refactor(docs): remove deprecated sharp benchmark results 346 | * refactor(docs): update preinstall.sh install URL 347 | * refactor(docs): use preinstall.sh script from bimg repository 348 | * fix(docs): Docker image link 349 | * fix(history) 350 | * fix(history) 351 | 352 | v0.1.28 / 2016-10-02 353 | ==================== 354 | 355 | * feat(docs): add placeholder docs and several refactors 356 | * feat(docs): add placeholder docs and several refactors 357 | * feat(#94): support placeholder image 358 | * feat(version): bump to v0.1.28 359 | * feat(version): release v0.1.28 360 | * feat(core): support bimg@1.0.5, support extend background param 361 | * chore(history): add Docker Go 1.7.1 support 362 | * feat(docker): use Go 1.7.1 363 | 364 | v0.1.27 / 2016-09-28 365 | ==================== 366 | 367 | * fix(server): mount route 368 | * refactor(server): DRYer path prefix 369 | * Merge pull request #93 from h2non/develop 370 | * fix(tests): type tests based on libvips runtime support 371 | * feat(version): bump 372 | * feat(travis): add Go 1.7 373 | * feat(docs): add new formats support 374 | * fix(history): update to bimg@1.0.3 375 | * fix(controllers): fix binary image processing 376 | * refactor(controllers) 377 | * Merge branch 'develop' of github.com:h2non/imaginary into develop 378 | * feat(core): add additional image formats 379 | * feat(core): add support for bimg@1.0.2 and new image formats 380 | * Merge pull request #90 from iosphere/feature/path-prefix 381 | * Add `path-prefix` flag to bind to an url path 382 | * Merge pull request #89 from h2non/develop 383 | * refactor(cli): update flag description 384 | * feat(docs): improve CLI docs 385 | * refactor(cli): improve description for -authorization flag 386 | 387 | v0.1.26 / 2016-09-06 388 | ==================== 389 | 390 | * fix(merge): master 391 | * chore(history): update history changelog 392 | * feat(docs): update CLI usage and help 393 | * feat: forward authorization headers support 394 | * Fix description for URL source, and allowed origins server options (#83) 395 | * fix(version): ups, editing from iPad 396 | * fix(version): unresolved conflict 397 | * merge: fix History conflicts 398 | * Merge branch 'develop' 399 | * Fix Expires and Cache-Control headers to be valid (#77) 400 | * Update README.md (#74) 401 | * Promote version 0.1.24 (#73) 402 | 403 | 0.1.25 / 2016-05-27 404 | =================== 405 | 406 | * Sync develop (#82) 407 | * feat(version): bump 408 | * fix(#79): infer buffer type via magic numbers signature 409 | * Sync develop (#80) 410 | 411 | v0.1.24 / 2016-04-21 412 | ==================== 413 | 414 | * feat(version): bump 415 | * Merge branch 'develop' of github.com:h2non/imaginary into develop 416 | * Sync develop (#72) 417 | * merge(upstream) 418 | * refactor(travis) 419 | * feat(bimg): bump version to v1 420 | * Sync develop (#71) 421 | * add background param (#69) 422 | * Merge pull request #70 from h2non/develop 423 | * fix(docs): typo 424 | * fix(docs): minor typos 425 | * Merge pull request #64 from h2non/develop 426 | * Merge pull request #63 from h2non/develop 427 | 428 | 0.1.23 / 2016-04-06 429 | =================== 430 | 431 | * feat(docs): add flip flop params 432 | * feat(version): bump 433 | * feat(#66): flip/flop support as param 434 | * feat(timeout): increase read/write timeout to 60 seconds 435 | 436 | 0.1.22 / 2016-02-20 437 | =================== 438 | 439 | * feat(docker): use SHA256 checksum 440 | * feat: update history 441 | * feat(version): bump 442 | * feat(docs) 443 | * feat(#62): support allowed origins 444 | * feat(#62): support allowed origins 445 | * Merge pull request #61 from h2non/master 446 | * feat(travis): use go 1.6 447 | * Merge pull request #60 from h2non/develop 448 | * feat(history): add change log 449 | * Merge pull request #59 from h2non/develop 450 | * Merge pull request #58 from h2non/develop 451 | 452 | 0.1.21 / 2016-02-09 453 | =================== 454 | 455 | * feat(version): bump 456 | 457 | 0.1.20 / 2016-02-06 458 | =================== 459 | 460 | * feat(version): bump 461 | * feat(docs): add PKGCONFIg variable 462 | * merge(master) 463 | * Merge pull request #57 from h2non/develop 464 | * Merge pull request #56 from h2non/develop 465 | * Merge pull request #55 from h2non/develop 466 | * Merge pull request #54 from pra85/patch-1 467 | * Typo fixes 468 | * fix(docs): typo in scalability 469 | * Merge pull request #53 from h2non/develop 470 | * feat(docs): add imaginary badge 471 | * refactor(docs): improve scalability notes 472 | * feat(docs): add docker pulls badge 473 | * feat(docs): add form data spec 474 | 475 | 0.1.19 / 2016-01-30 476 | =================== 477 | 478 | * refactor(form): use previous params 479 | * feat(docs): add rotate param in endpoints 480 | * feat(version): bump 481 | * feat(#49): support custom form field 482 | * fix(docs): minor typo 483 | * feat: add more tests, partially document code 484 | * refactor(controllers): use external struct 485 | * refactor: follow go idioms 486 | * refactor(middleware): rename function 487 | * refactor(middleware): only cache certain requests 488 | * fix(docs): use proper flag 489 | * fix(docs): add supported method 490 | * fix(cli): bad flag description 491 | * fix 492 | * refactor(health) 493 | * refactor 494 | * feat: ignore imaginary root binary 495 | * refactor(middleware) 496 | * feat(docs): add examples 497 | * feat(docs): update CLI help 498 | 499 | 0.1.18 / 2015-11-04 500 | =================== 501 | 502 | * feat(version): bump 503 | * fix(badge) 504 | * fix(badge) 505 | * merge(upstream) 506 | * feat(docs): add remote URL support, update badges 507 | * refactor(cli): change flag 508 | * feat(#43, #35): support gravity param and health 509 | * feat(#32): add test coverage 510 | * feat(#32): initial support for URL processing 511 | * fix(tests) 512 | * feat(#32): support flags 513 | * feat(#32): initial seed implementation 514 | * Merge pull request #44 from freeformz/master 515 | * Add Heroku Button Support 516 | * fix(docs): content typo 517 | * feat: add glide.yaml for vendording packages 518 | * feat: add glide.yaml for vendording packages 519 | * refactor(docs): add performance note 520 | * refactor(docs) 521 | * refactor(benchmark): uncomment kill sentence 522 | * feat(docs): add benchmark notes 523 | * refactor(image): add default error on panic 524 | * feat: add panic handler. feat(docs): add error docs 525 | 526 | 0.1.17 / 2015-10-31 527 | =================== 528 | 529 | * feat(version): bump 530 | * Merge pull request #39 from Dynom/addingHttpCaching 531 | * Added documentation. 532 | * More style fixes. 533 | * Removing redundant construct 534 | * Fixing coding-style 535 | * Merge pull request #41 from Dynom/enablingSecureDownloadOfGo 536 | * Added the CA certs so that the --insecure flag can be removed from the GO installer. 537 | * Added a sanity check for the value of the -http-cache-ttl flag. 538 | * Added -http-cache-ttl flag 539 | * feat(log): add comments 540 | * refactor(body) 541 | * refactor(benchmark) 542 | * refactor(benchmark) 543 | * refactor: rename function 544 | * refactor: normalize statements, add minor docs 545 | * refactor(docs): add link 546 | * feat(docs): add toc 547 | 548 | 0.1.16 / 2015-10-06 549 | =================== 550 | 551 | * fix(docker): restore to default 552 | * refactor(docker): uses latest version 553 | * feat(version): bump 554 | * fix(#31): use libvips 7.42 docker tag 555 | * refactor(docs): update descriptiong 556 | * feat(docs): add libvips version compatibility note 557 | * merge(upstream) 558 | * refactor(docs): add root endpoint, fix minor typos 559 | * refactor(docs): description 560 | * feat(docs): add sourcegraph badge 561 | * refactor(docs): minor changes, reorder 562 | 563 | 0.1.15 / 2015-09-29 564 | =================== 565 | 566 | * feat(version): bump 567 | * merge: upstream 568 | * feat: expose libvips and bimg version in index route 569 | * refactor(docs): add docker debug command 570 | 571 | 0.1.14 / 2015-08-30 572 | =================== 573 | 574 | * fix: build 575 | * refactor(docker): bump Go version 576 | * feat(version): bump 577 | * feat: use throttle v2 578 | * refactor(make): push specific tag 579 | 580 | 0.1.13 / 2015-08-10 581 | =================== 582 | 583 | * feat(version): bump 584 | * feat(#30) 585 | 586 | 0.1.12 / 2015-07-29 587 | =================== 588 | 589 | * feat(version): bump 590 | * fix(dependency) 591 | * refactor: add errors as constants. middleware 592 | * refactor: add errors as constants. middleware 593 | * fix(docs): typo 594 | * fix(travis): remove go tip build due to install.sh error 595 | * refactor: server router 596 | * refactor: 597 | * fix(docs): add missing params per specific method 598 | * feat(docs): add image 599 | * feat(docs): add link 600 | 601 | 0.1.11 / 2015-07-11 602 | =================== 603 | 604 | * feat(version): bump 605 | * feat(#26): add TLS support 606 | * feat(#27) 607 | * feat(#27) 608 | * refactor(docs) 609 | * fix(docs): description 610 | * fix 611 | * feat: merge 612 | * refactor(form): dry 613 | * refactor(docs): http api 614 | * refactor(main) 615 | 616 | 0.1.10 / 2015-06-30 617 | =================== 618 | 619 | * feat(version): bump 620 | * refactor(docs) 621 | * feat(#25): several refactors and test coverage 622 | * feat(#25): experimental support for local files processing 623 | * feat: support no profile param 624 | * feat(http): add bimg version header 625 | * feat(http): add bimg header 626 | * refactor(docs): node graph 627 | 628 | 0.1.9 / 2015-06-12 629 | ================== 630 | 631 | * refactor: disable interlace by default (due to performance issues) 632 | * feat(version): bump 633 | * feat: add interlace support by default 634 | * refactor(image): remove debug statement 635 | * refactor(docs): description 636 | * fix(form): add proper param for watermark 637 | * fix(form): add proper param for watermark 638 | * refactor(docs): description 639 | * refactor(params): use math function 640 | 641 | 0.1.8 / 2015-05-24 642 | ================== 643 | 644 | * feat(version): bump 645 | * feat(version): bump 646 | * fix(form): bad param 647 | * refactor(docs): scalability 648 | * refactor(docs): benchmark 649 | * refactor(docs): description 650 | * refactor(docs): usage 651 | * refactor(docs): update sections 652 | * refactor(bench). feat(docs): add resources and scalability notes 653 | * refactor(docs) 654 | * refact(bench) 655 | * feat(docs): add production note 656 | * merge 657 | * refactor(server): isolate throttle to middleware 658 | * fix(docs): duplicated param 659 | 660 | 0.1.7 / 2015-04-27 661 | ================== 662 | 663 | * fix(extract): bad query param 664 | * feat(version): bump 665 | * fix(enlarge): bad params assignment 666 | * feat(#24): crop by default 667 | 668 | 0.1.6 / 2015-04-26 669 | ================== 670 | 671 | * feat(version): bump (maintenance release) 672 | 673 | 0.1.5 / 2015-04-25 674 | ================== 675 | 676 | * feat(version): bump 677 | * feat(params): add params for no auto rotate 678 | * feat(params): add params for no auto rotate 679 | * refactor(docs): description 680 | * fix(docs): description 681 | * refactor(docs): description 682 | * refactor(docs): add new Heroku steps 683 | * refactor(buildpack) 684 | * refactor(buildpack) 685 | * refactor(buildpack) 686 | * refactor(buildpack) 687 | * fix(heroku) 688 | * refactor: update buildpack 689 | 690 | 0.1.4 / 2015-04-19 691 | ================== 692 | 693 | * feat(version): bump 694 | * feat: handle HTTP 404 695 | * feat(heroku): update docs 696 | * feat: update buildpack 697 | * feat 698 | * refactor(heroku) 699 | * fix(heroku): buildpack order 700 | * feat(#23) 701 | 702 | 0.1.3 / 2015-04-19 703 | ================== 704 | 705 | * feat(version): bumo 706 | * fix(port) 707 | * refactor(docker): remove help flag 708 | * refactor: heroku 709 | * refactor 710 | * refactor 711 | * refactor 712 | * refactor 713 | * feat: add dependencies 714 | * feat: add dependencies 715 | * feat: add heroku files 716 | * feat: add Heroku files 717 | * refactor(docs): update description 718 | * refactor(image) 719 | * fix(docs) 720 | 721 | 0.1.2 / 2015-04-18 722 | ================== 723 | 724 | * refactor(bench) 725 | * feat(docs): better Heroku docs 726 | * refactor: split parse params and body read 727 | * feat(docs): add server clients 728 | * refactor(docs) 729 | * fix(form): add query param 730 | * fix(docs): usage 731 | * fix(cli): memory release 732 | * fix(travis) 733 | 734 | 0.1.1 / 2015-04-15 735 | ================== 736 | 737 | * feat(version): bump 738 | * feat(#20) fix(#2) 739 | * feat: refactor 740 | * refactor(bench) 741 | * refactor(docs) 742 | * feat(docs): add benchmarks 743 | * feat(docs): add benchmarks 744 | * feat(docs): add benchmarks 745 | * feat(#16): add benchmark 746 | * refactor(docs) 747 | * refactor(docs) 748 | 749 | 0.1.0 / 2015-04-13 750 | ================== 751 | 752 | * feat(#18): http docs 753 | * fix(travis): another attempt 754 | * fix(travis) 755 | * fix(docs) 756 | * fix(travis) 757 | * fix(travis) 758 | * refactor(docs): docker 759 | * refactor(cli): priorize CLI flag 760 | * refactor(server) 761 | * fix(travis): pending issue from Coveralls 762 | * feat: add Makefile 763 | * fix(package): name 764 | * feat(docs): add docker badge 765 | 766 | 0.1.0-rc.0 / 2015-04-12 767 | ======================= 768 | 769 | * feat(test): add test coverage 770 | * refactor 771 | * feat(#15): docker file settings 772 | * feat(#19, #10, #13) 773 | * feat(docs): add image 774 | * feat(docs): add image 775 | * feat(docs): add image 776 | * feat(image): add image 777 | * feat(#15, #11, #7, #5, #13) 778 | * feat(#17, #7, #2) 779 | * refactor: rename 780 | * refactor: remove file 781 | * refactor: rename 782 | * refactor: use bimg 783 | * refactor(image): options 784 | * feat: add upload test 785 | * refactor 786 | * refactor 787 | * feat: add Dockerfile 788 | * refactor: server 789 | * feat: add test 790 | * feat: add test 791 | * feat: add test 792 | * feat: add sources 793 | * feat: add files 794 | 795 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-2020 Tomas Aparicio and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OK_COLOR=\033[32;01m 2 | NO_COLOR=\033[0m 3 | 4 | build: 5 | @echo "$(OK_COLOR)==> Compiling binary$(NO_COLOR)" 6 | go test && go build -o bin/imaginary 7 | 8 | test: 9 | go test 10 | 11 | install: 12 | go get -u . 13 | 14 | benchmark: build 15 | bash benchmark.sh 16 | 17 | docker-build: 18 | @echo "$(OK_COLOR)==> Building Docker image$(NO_COLOR)" 19 | docker build --no-cache=true --build-arg IMAGINARY_VERSION=$(VERSION) -t h2non/imaginary:$(VERSION) . 20 | 21 | docker-push: 22 | @echo "$(OK_COLOR)==> Pushing Docker image v$(VERSION) $(NO_COLOR)" 23 | docker push h2non/imaginary:$(VERSION) 24 | 25 | docker: docker-build docker-push 26 | 27 | .PHONY: test benchmark docker-build docker-push docker 28 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: imaginary 2 | -------------------------------------------------------------------------------- /benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Simple benchmark test suite 4 | # 5 | # You must have installed vegeta: 6 | # go get github.com/tsenart/vegeta 7 | # 8 | 9 | # Default port to listen 10 | port=8088 11 | 12 | # Start the server 13 | ./bin/imaginary -p $port & > /dev/null 14 | pid=$! 15 | 16 | suite() { 17 | echo "$1 --------------------------------------" 18 | echo "POST http://localhost:$port/$2" | vegeta attack \ 19 | -duration=30s \ 20 | -rate=50 \ 21 | -body="./testdata/large.jpg" \ | vegeta report 22 | sleep 1 23 | } 24 | 25 | # Run suites 26 | suite "Crop" "crop?width=800&height=600" 27 | suite "Resize" "resize?width=200" 28 | #suite "Rotate" "rotate?rotate=180" 29 | #suite "Enlarge" "enlarge?width=1600&height=1200" 30 | suite "Extract" "extract?top=50&left=50&areawidth=200&areaheight=200" 31 | 32 | # Kill the server 33 | kill -9 $pid 34 | -------------------------------------------------------------------------------- /controllers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "mime" 7 | "net/http" 8 | "path" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/h2non/bimg" 13 | "github.com/h2non/filetype" 14 | ) 15 | 16 | func indexController(o ServerOptions) func(w http.ResponseWriter, r *http.Request) { 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | if r.URL.Path != path.Join(o.PathPrefix, "/") { 19 | ErrorReply(r, w, ErrNotFound, ServerOptions{}) 20 | return 21 | } 22 | 23 | body, _ := json.Marshal(Versions{ 24 | Version, 25 | bimg.Version, 26 | bimg.VipsVersion, 27 | }) 28 | w.Header().Set("Content-Type", "application/json") 29 | _, _ = w.Write(body) 30 | } 31 | } 32 | 33 | func healthController(w http.ResponseWriter, r *http.Request) { 34 | health := GetHealthStats() 35 | body, _ := json.Marshal(health) 36 | w.Header().Set("Content-Type", "application/json") 37 | _, _ = w.Write(body) 38 | } 39 | 40 | func imageController(o ServerOptions, operation Operation) func(http.ResponseWriter, *http.Request) { 41 | return func(w http.ResponseWriter, req *http.Request) { 42 | var imageSource = MatchSource(req) 43 | if imageSource == nil { 44 | ErrorReply(req, w, ErrMissingImageSource, o) 45 | return 46 | } 47 | 48 | buf, err := imageSource.GetImage(req) 49 | if err != nil { 50 | if xerr, ok := err.(Error); ok { 51 | ErrorReply(req, w, xerr, o) 52 | } else { 53 | ErrorReply(req, w, NewError(err.Error(), http.StatusBadRequest), o) 54 | } 55 | return 56 | } 57 | 58 | if len(buf) == 0 { 59 | ErrorReply(req, w, ErrEmptyBody, o) 60 | return 61 | } 62 | 63 | imageHandler(w, req, buf, operation, o) 64 | } 65 | } 66 | 67 | func determineAcceptMimeType(accept string) string { 68 | for _, v := range strings.Split(accept, ",") { 69 | mediaType, _, _ := mime.ParseMediaType(v) 70 | switch mediaType { 71 | case "image/webp": 72 | return "webp" 73 | case "image/png": 74 | return "png" 75 | case "image/jpeg": 76 | return "jpeg" 77 | } 78 | } 79 | 80 | return "" 81 | } 82 | 83 | func imageHandler(w http.ResponseWriter, r *http.Request, buf []byte, operation Operation, o ServerOptions) { 84 | // Infer the body MIME type via mime sniff algorithm 85 | mimeType := http.DetectContentType(buf) 86 | 87 | // If cannot infer the type, infer it via magic numbers 88 | if mimeType == "application/octet-stream" { 89 | kind, err := filetype.Get(buf) 90 | if err == nil && kind.MIME.Value != "" { 91 | mimeType = kind.MIME.Value 92 | } 93 | } 94 | 95 | // Infer text/plain responses as potential SVG image 96 | if strings.Contains(mimeType, "text/plain") && len(buf) > 8 { 97 | if bimg.IsSVGImage(buf) { 98 | mimeType = "image/svg+xml" 99 | } 100 | } 101 | 102 | // Finally check if image MIME type is supported 103 | if !IsImageMimeTypeSupported(mimeType) { 104 | ErrorReply(r, w, ErrUnsupportedMedia, o) 105 | return 106 | } 107 | 108 | opts, err := buildParamsFromQuery(r.URL.Query()) 109 | if err != nil { 110 | ErrorReply(r, w, NewError("Error while processing parameters, "+err.Error(), http.StatusBadRequest), o) 111 | return 112 | } 113 | 114 | vary := "" 115 | if opts.Type == "auto" { 116 | opts.Type = determineAcceptMimeType(r.Header.Get("Accept")) 117 | vary = "Accept" // Ensure caches behave correctly for negotiated content 118 | } else if opts.Type != "" && ImageType(opts.Type) == 0 { 119 | ErrorReply(r, w, ErrOutputFormat, o) 120 | return 121 | } 122 | 123 | sizeInfo, err := bimg.Size(buf) 124 | 125 | if err != nil { 126 | ErrorReply(r, w, NewError("Error while processing the image: "+err.Error(), http.StatusBadRequest), o) 127 | return 128 | } 129 | 130 | // https://en.wikipedia.org/wiki/Image_resolution#Pixel_count 131 | imgResolution := float64(sizeInfo.Width) * float64(sizeInfo.Height) 132 | 133 | if (imgResolution / 1000000) > o.MaxAllowedPixels { 134 | ErrorReply(r, w, ErrResolutionTooBig, o) 135 | return 136 | } 137 | 138 | image, err := operation.Run(buf, opts) 139 | if err != nil { 140 | // Ensure the Vary header is set when an error occurs 141 | if vary != "" { 142 | w.Header().Set("Vary", vary) 143 | } 144 | ErrorReply(r, w, NewError("Error while processing the image: "+err.Error(), http.StatusBadRequest), o) 145 | return 146 | } 147 | 148 | // Expose Content-Length response header 149 | w.Header().Set("Content-Length", strconv.Itoa(len(image.Body))) 150 | w.Header().Set("Content-Type", image.Mime) 151 | if image.Mime != "application/json" && o.ReturnSize { 152 | meta, err := bimg.Metadata(image.Body) 153 | if err == nil { 154 | w.Header().Set("Image-Width", strconv.Itoa(meta.Size.Width)) 155 | w.Header().Set("Image-Height", strconv.Itoa(meta.Size.Height)) 156 | } 157 | } 158 | if vary != "" { 159 | w.Header().Set("Vary", vary) 160 | } 161 | _, _ = w.Write(image.Body) 162 | } 163 | 164 | func formController(o ServerOptions) func(w http.ResponseWriter, r *http.Request) { 165 | return func(w http.ResponseWriter, r *http.Request) { 166 | operations := []struct { 167 | name string 168 | method string 169 | args string 170 | }{ 171 | {"Resize", "resize", "width=300&height=200&type=jpeg"}, 172 | {"Force resize", "resize", "width=300&height=200&force=true"}, 173 | {"Crop", "crop", "width=300&quality=95"}, 174 | {"SmartCrop", "crop", "width=300&height=260&quality=95&gravity=smart"}, 175 | {"Extract", "extract", "top=100&left=100&areawidth=300&areaheight=150"}, 176 | {"Enlarge", "enlarge", "width=1440&height=900&quality=95"}, 177 | {"Rotate", "rotate", "rotate=180"}, 178 | {"AutoRotate", "autorotate", "quality=90"}, 179 | {"Flip", "flip", ""}, 180 | {"Flop", "flop", ""}, 181 | {"Thumbnail", "thumbnail", "width=100"}, 182 | {"Zoom", "zoom", "factor=2&areawidth=300&top=80&left=80"}, 183 | {"Color space (black&white)", "resize", "width=400&height=300&colorspace=bw"}, 184 | {"Add watermark", "watermark", "textwidth=100&text=Hello&font=sans%2012&opacity=0.5&color=255,200,50"}, 185 | {"Convert format", "convert", "type=png"}, 186 | {"Image metadata", "info", ""}, 187 | {"Gaussian blur", "blur", "sigma=15.0&minampl=0.2"}, 188 | {"Pipeline (image reduction via multiple transformations)", "pipeline", "operations=%5B%7B%22operation%22:%20%22crop%22,%20%22params%22:%20%7B%22width%22:%20300,%20%22height%22:%20260%7D%7D,%20%7B%22operation%22:%20%22convert%22,%20%22params%22:%20%7B%22type%22:%20%22webp%22%7D%7D%5D"}, 189 | } 190 | 191 | html := "" 192 | 193 | for _, form := range operations { 194 | html += fmt.Sprintf(` 195 |

%s

196 |
197 | 198 | 199 |
`, path.Join(o.PathPrefix, form.name), path.Join(o.PathPrefix, form.method), form.args) 200 | } 201 | 202 | html += "" 203 | 204 | w.Header().Set("Content-Type", "text/html") 205 | _, _ = w.Write([]byte(html)) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | imaginary: 5 | image: h2non/imaginary 6 | ports: 7 | - "8088:8088" 8 | environment: 9 | - PORT=8088 10 | command: -concurrency 50 -enable-url-source 11 | 12 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/h2non/bimg" 10 | ) 11 | 12 | var ( 13 | ErrNotFound = NewError("Not found", http.StatusNotFound) 14 | ErrInvalidAPIKey = NewError("Invalid or missing API key", http.StatusUnauthorized) 15 | ErrMethodNotAllowed = NewError("HTTP method not allowed. Try with a POST or GET method (-enable-url-source flag must be defined)", http.StatusMethodNotAllowed) 16 | ErrGetMethodNotAllowed = NewError("GET method not allowed. Make sure remote URL source is enabled by using the flag: -enable-url-source", http.StatusMethodNotAllowed) 17 | ErrUnsupportedMedia = NewError("Unsupported media type", http.StatusNotAcceptable) 18 | ErrOutputFormat = NewError("Unsupported output image format", http.StatusBadRequest) 19 | ErrEmptyBody = NewError("Empty or unreadable image", http.StatusBadRequest) 20 | ErrMissingParamFile = NewError("Missing required param: file", http.StatusBadRequest) 21 | ErrInvalidFilePath = NewError("Invalid file path", http.StatusBadRequest) 22 | ErrInvalidImageURL = NewError("Invalid image URL", http.StatusBadRequest) 23 | ErrMissingImageSource = NewError("Cannot process the image due to missing or invalid params", http.StatusBadRequest) 24 | ErrNotImplemented = NewError("Not implemented endpoint", http.StatusNotImplemented) 25 | ErrInvalidURLSignature = NewError("Invalid URL signature", http.StatusBadRequest) 26 | ErrURLSignatureMismatch = NewError("URL signature mismatch", http.StatusForbidden) 27 | ErrResolutionTooBig = NewError("Image resolution is too big", http.StatusUnprocessableEntity) 28 | ) 29 | 30 | type Error struct { 31 | Message string `json:"message,omitempty"` 32 | Code int `json:"status"` 33 | } 34 | 35 | func (e Error) JSON() []byte { 36 | buf, _ := json.Marshal(e) 37 | return buf 38 | } 39 | 40 | func (e Error) Error() string { 41 | return e.Message 42 | } 43 | 44 | func (e Error) HTTPCode() int { 45 | if e.Code >= 400 && e.Code <= 511 { 46 | return e.Code 47 | } 48 | return http.StatusServiceUnavailable 49 | } 50 | 51 | func NewError(err string, code int) Error { 52 | err = strings.Replace(err, "\n", "", -1) 53 | return Error{Message: err, Code: code} 54 | } 55 | 56 | func sendErrorResponse(w http.ResponseWriter, httpStatusCode int, err error) { 57 | w.Header().Set("Content-Type", "application/json") 58 | w.WriteHeader(httpStatusCode) 59 | _, _ = w.Write([]byte(fmt.Sprintf("{\"error\":\"%s\", \"status\": %d}", err.Error(), httpStatusCode))) 60 | } 61 | 62 | func replyWithPlaceholder(req *http.Request, w http.ResponseWriter, errCaller Error, o ServerOptions) error { 63 | var err error 64 | bimgOptions := bimg.Options{ 65 | Force: true, 66 | Crop: true, 67 | Enlarge: true, 68 | Type: ImageType(req.URL.Query().Get("type")), 69 | } 70 | 71 | bimgOptions.Width, err = parseInt(req.URL.Query().Get("width")) 72 | if err != nil { 73 | sendErrorResponse(w, http.StatusBadRequest, err) 74 | return err 75 | } 76 | 77 | bimgOptions.Height, err = parseInt(req.URL.Query().Get("height")) 78 | if err != nil { 79 | sendErrorResponse(w, http.StatusBadRequest, err) 80 | return err 81 | } 82 | 83 | // Resize placeholder to expected output 84 | buf, err := bimg.Resize(o.PlaceholderImage, bimgOptions) 85 | if err != nil { 86 | sendErrorResponse(w, http.StatusBadRequest, err) 87 | return err 88 | } 89 | 90 | // Use final response body image 91 | image := buf 92 | 93 | // Placeholder image response 94 | w.Header().Set("Content-Type", GetImageMimeType(bimg.DetermineImageType(image))) 95 | w.Header().Set("Error", string(errCaller.JSON())) 96 | if o.PlaceholderStatus != 0 { 97 | w.WriteHeader(o.PlaceholderStatus) 98 | } else { 99 | w.WriteHeader(errCaller.HTTPCode()) 100 | } 101 | _, _ = w.Write(image) 102 | 103 | return errCaller 104 | } 105 | 106 | func ErrorReply(req *http.Request, w http.ResponseWriter, err Error, o ServerOptions) { 107 | // Reply with placeholder if required 108 | if o.EnablePlaceholder || o.Placeholder != "" { 109 | _ = replyWithPlaceholder(req, w, err, o) 110 | return 111 | } 112 | 113 | w.Header().Set("Content-Type", "application/json") 114 | w.WriteHeader(err.HTTPCode()) 115 | _, _ = w.Write(err.JSON()) 116 | } 117 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestDefaultError(t *testing.T) { 6 | err := NewError("oops!\n\n", 503) 7 | 8 | if err.Error() != "oops!" { 9 | t.Fatal("Invalid error message") 10 | } 11 | if err.Code != 503 { 12 | t.Fatal("Invalid error code") 13 | } 14 | 15 | code := err.HTTPCode() 16 | if code != 503 { 17 | t.Fatalf("Invalid HTTP error status: %d", code) 18 | } 19 | 20 | json := string(err.JSON()) 21 | if json != "{\"message\":\"oops!\",\"status\":503}" { 22 | t.Fatalf("Invalid JSON output: %s", json) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/h2non/imaginary 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/garyburd/redigo v1.6.0 // indirect 7 | github.com/h2non/bimg v1.1.7 8 | github.com/h2non/filetype v1.1.0 9 | github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad // indirect 10 | github.com/rs/cors v0.0.0-20170727213201-7af7a1e09ba3 11 | gopkg.in/throttled/throttled.v2 v2.0.3 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc= 2 | github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= 3 | github.com/h2non/bimg v1.1.7 h1:JKJe70nDNMWp2wFnTLMGB8qJWQQMaKRn56uHmC/4+34= 4 | github.com/h2non/bimg v1.1.7/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= 5 | github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA= 6 | github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 7 | github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad h1:eMxs9EL0PvIGS9TTtxg4R+JxuPGav82J8rA+GFnY7po= 8 | github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 9 | github.com/rs/cors v0.0.0-20170727213201-7af7a1e09ba3 h1:86ukAHRTa2CXdBnWJHcjjPPGTyLGEF488OFRsbBAuFs= 10 | github.com/rs/cors v0.0.0-20170727213201-7af7a1e09ba3/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= 11 | gopkg.in/throttled/throttled.v2 v2.0.3 h1:PGm7nfjjexecEyI2knw1akeLcrjzqxuYSU9a04R8rfU= 12 | gopkg.in/throttled/throttled.v2 v2.0.3/go.mod h1:L4cTNZO77XKEXtn8HNFRCMNGZPtRRKAhyuJBSvK/T90= 13 | -------------------------------------------------------------------------------- /health.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "runtime" 6 | "time" 7 | ) 8 | 9 | var start = time.Now() 10 | 11 | const MB float64 = 1.0 * 1024 * 1024 12 | 13 | type HealthStats struct { 14 | Uptime int64 `json:"uptime"` 15 | AllocatedMemory float64 `json:"allocatedMemory"` 16 | TotalAllocatedMemory float64 `json:"totalAllocatedMemory"` 17 | Goroutines int `json:"goroutines"` 18 | GCCycles uint32 `json:"completedGCCycles"` 19 | NumberOfCPUs int `json:"cpus"` 20 | HeapSys float64 `json:"maxHeapUsage"` 21 | HeapAllocated float64 `json:"heapInUse"` 22 | ObjectsInUse uint64 `json:"objectsInUse"` 23 | OSMemoryObtained float64 `json:"OSMemoryObtained"` 24 | } 25 | 26 | func GetHealthStats() *HealthStats { 27 | mem := &runtime.MemStats{} 28 | runtime.ReadMemStats(mem) 29 | 30 | return &HealthStats{ 31 | Uptime: GetUptime(), 32 | AllocatedMemory: toMegaBytes(mem.Alloc), 33 | TotalAllocatedMemory: toMegaBytes(mem.TotalAlloc), 34 | Goroutines: runtime.NumGoroutine(), 35 | NumberOfCPUs: runtime.NumCPU(), 36 | GCCycles: mem.NumGC, 37 | HeapSys: toMegaBytes(mem.HeapSys), 38 | HeapAllocated: toMegaBytes(mem.HeapAlloc), 39 | ObjectsInUse: mem.Mallocs - mem.Frees, 40 | OSMemoryObtained: toMegaBytes(mem.Sys), 41 | } 42 | } 43 | 44 | func GetUptime() int64 { 45 | return time.Now().Unix() - start.Unix() 46 | } 47 | 48 | func toMegaBytes(bytes uint64) float64 { 49 | return toFixed(float64(bytes)/MB, 2) 50 | } 51 | 52 | func round(num float64) int { 53 | return int(num + math.Copysign(0.5, num)) 54 | } 55 | 56 | func toFixed(num float64, precision int) float64 { 57 | output := math.Pow(10, float64(precision)) 58 | return float64(round(num*output)) / output 59 | } 60 | -------------------------------------------------------------------------------- /health_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestToMegaBytes(t *testing.T) { 6 | tests := []struct { 7 | value uint64 8 | expected float64 9 | }{ 10 | {1024, 0}, 11 | {1024 * 1024, 1}, 12 | {1024 * 1024 * 10, 10}, 13 | {1024 * 1024 * 100, 100}, 14 | {1024 * 1024 * 250, 250}, 15 | } 16 | 17 | for _, test := range tests { 18 | val := toMegaBytes(test.value) 19 | if val != test.expected { 20 | t.Errorf("Invalid param: %#v != %#v", val, test.expected) 21 | } 22 | } 23 | } 24 | 25 | func TestRound(t *testing.T) { 26 | tests := []struct { 27 | value float64 28 | expected int 29 | }{ 30 | {0, 0}, 31 | {1, 1}, 32 | {1.56, 2}, 33 | {1.38, 1}, 34 | {30.12, 30}, 35 | } 36 | 37 | for _, test := range tests { 38 | val := round(test.value) 39 | if val != test.expected { 40 | t.Errorf("Invalid param: %#v != %#v", val, test.expected) 41 | } 42 | } 43 | } 44 | 45 | func TestToFixed(t *testing.T) { 46 | tests := []struct { 47 | value float64 48 | expected float64 49 | }{ 50 | {0, 0}, 51 | {1, 1}, 52 | {123, 123}, 53 | {0.99, 1}, 54 | {1.02, 1}, 55 | {1.82, 1.8}, 56 | {1.56, 1.6}, 57 | {1.38, 1.4}, 58 | } 59 | 60 | for _, test := range tests { 61 | val := toFixed(test.value, 1) 62 | if val != test.expected { 63 | t.Errorf("Invalid param: %#v != %#v", val, test.expected) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "math" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/h2non/bimg" 14 | ) 15 | 16 | // OperationsMap defines the allowed image transformation operations listed by name. 17 | // Used for pipeline image processing. 18 | var OperationsMap = map[string]Operation{ 19 | "crop": Crop, 20 | "resize": Resize, 21 | "enlarge": Enlarge, 22 | "extract": Extract, 23 | "rotate": Rotate, 24 | "autorotate": AutoRotate, 25 | "flip": Flip, 26 | "flop": Flop, 27 | "thumbnail": Thumbnail, 28 | "zoom": Zoom, 29 | "convert": Convert, 30 | "watermark": Watermark, 31 | "watermarkImage": WatermarkImage, 32 | "blur": GaussianBlur, 33 | "smartcrop": SmartCrop, 34 | "fit": Fit, 35 | } 36 | 37 | // Image stores an image binary buffer and its MIME type 38 | type Image struct { 39 | Body []byte 40 | Mime string 41 | } 42 | 43 | // Operation implements an image transformation runnable interface 44 | type Operation func([]byte, ImageOptions) (Image, error) 45 | 46 | // Run performs the image transformation 47 | func (o Operation) Run(buf []byte, opts ImageOptions) (Image, error) { 48 | return o(buf, opts) 49 | } 50 | 51 | // ImageInfo represents an image details and additional metadata 52 | type ImageInfo struct { 53 | Width int `json:"width"` 54 | Height int `json:"height"` 55 | Type string `json:"type"` 56 | Space string `json:"space"` 57 | Alpha bool `json:"hasAlpha"` 58 | Profile bool `json:"hasProfile"` 59 | Channels int `json:"channels"` 60 | Orientation int `json:"orientation"` 61 | } 62 | 63 | func Info(buf []byte, o ImageOptions) (Image, error) { 64 | // We're not handling an image here, but we reused the struct. 65 | // An interface will be definitively better here. 66 | image := Image{Mime: "application/json"} 67 | 68 | meta, err := bimg.Metadata(buf) 69 | if err != nil { 70 | return image, NewError("Cannot retrieve image metadata: %s"+err.Error(), http.StatusBadRequest) 71 | } 72 | 73 | info := ImageInfo{ 74 | Width: meta.Size.Width, 75 | Height: meta.Size.Height, 76 | Type: meta.Type, 77 | Space: meta.Space, 78 | Alpha: meta.Alpha, 79 | Profile: meta.Profile, 80 | Channels: meta.Channels, 81 | Orientation: meta.Orientation, 82 | } 83 | 84 | body, _ := json.Marshal(info) 85 | image.Body = body 86 | 87 | return image, nil 88 | } 89 | 90 | func Resize(buf []byte, o ImageOptions) (Image, error) { 91 | if o.Width == 0 && o.Height == 0 { 92 | return Image{}, NewError("Missing required param: height or width", http.StatusBadRequest) 93 | } 94 | 95 | opts := BimgOptions(o) 96 | opts.Embed = true 97 | 98 | if o.IsDefinedField.NoCrop { 99 | opts.Crop = !o.NoCrop 100 | } 101 | 102 | return Process(buf, opts) 103 | } 104 | 105 | func Fit(buf []byte, o ImageOptions) (Image, error) { 106 | if o.Width == 0 || o.Height == 0 { 107 | return Image{}, NewError("Missing required params: height, width", http.StatusBadRequest) 108 | } 109 | 110 | metadata, err := bimg.Metadata(buf) 111 | if err != nil { 112 | return Image{}, err 113 | } 114 | 115 | dims := metadata.Size 116 | 117 | if dims.Width == 0 || dims.Height == 0 { 118 | return Image{}, NewError("Width or height of requested image is zero", http.StatusNotAcceptable) 119 | } 120 | 121 | // metadata.Orientation 122 | // 0: no EXIF orientation 123 | // 1: CW 0 124 | // 2: CW 0, flip horizontal 125 | // 3: CW 180 126 | // 4: CW 180, flip horizontal 127 | // 5: CW 90, flip horizontal 128 | // 6: CW 270 129 | // 7: CW 270, flip horizontal 130 | // 8: CW 90 131 | 132 | var originHeight, originWidth int 133 | var fitHeight, fitWidth *int 134 | if o.NoRotation || (metadata.Orientation <= 4) { 135 | originHeight = dims.Height 136 | originWidth = dims.Width 137 | fitHeight = &o.Height 138 | fitWidth = &o.Width 139 | } else { 140 | // width/height will be switched with auto rotation 141 | originWidth = dims.Height 142 | originHeight = dims.Width 143 | fitWidth = &o.Height 144 | fitHeight = &o.Width 145 | } 146 | 147 | *fitWidth, *fitHeight = calculateDestinationFitDimension(originWidth, originHeight, *fitWidth, *fitHeight) 148 | 149 | opts := BimgOptions(o) 150 | opts.Embed = true 151 | 152 | return Process(buf, opts) 153 | } 154 | 155 | // calculateDestinationFitDimension calculates the fit area based on the image and desired fit dimensions 156 | func calculateDestinationFitDimension(imageWidth, imageHeight, fitWidth, fitHeight int) (int, int) { 157 | if imageWidth*fitHeight > fitWidth*imageHeight { 158 | // constrained by width 159 | fitHeight = int(math.Round(float64(fitWidth) * float64(imageHeight) / float64(imageWidth))) 160 | } else { 161 | // constrained by height 162 | fitWidth = int(math.Round(float64(fitHeight) * float64(imageWidth) / float64(imageHeight))) 163 | } 164 | 165 | return fitWidth, fitHeight 166 | } 167 | 168 | func Enlarge(buf []byte, o ImageOptions) (Image, error) { 169 | if o.Width == 0 || o.Height == 0 { 170 | return Image{}, NewError("Missing required params: height, width", http.StatusBadRequest) 171 | } 172 | 173 | opts := BimgOptions(o) 174 | opts.Enlarge = true 175 | 176 | // Since both width & height is required, we allow cropping by default. 177 | opts.Crop = !o.NoCrop 178 | 179 | return Process(buf, opts) 180 | } 181 | 182 | func Extract(buf []byte, o ImageOptions) (Image, error) { 183 | if o.AreaWidth == 0 || o.AreaHeight == 0 { 184 | return Image{}, NewError("Missing required params: areawidth or areaheight", http.StatusBadRequest) 185 | } 186 | 187 | opts := BimgOptions(o) 188 | opts.Top = o.Top 189 | opts.Left = o.Left 190 | opts.AreaWidth = o.AreaWidth 191 | opts.AreaHeight = o.AreaHeight 192 | 193 | return Process(buf, opts) 194 | } 195 | 196 | func Crop(buf []byte, o ImageOptions) (Image, error) { 197 | if o.Width == 0 && o.Height == 0 { 198 | return Image{}, NewError("Missing required param: height or width", http.StatusBadRequest) 199 | } 200 | 201 | opts := BimgOptions(o) 202 | opts.Crop = true 203 | return Process(buf, opts) 204 | } 205 | 206 | func SmartCrop(buf []byte, o ImageOptions) (Image, error) { 207 | if o.Width == 0 && o.Height == 0 { 208 | return Image{}, NewError("Missing required param: height or width", http.StatusBadRequest) 209 | } 210 | 211 | opts := BimgOptions(o) 212 | opts.Crop = true 213 | opts.Gravity = bimg.GravitySmart 214 | return Process(buf, opts) 215 | } 216 | 217 | func Rotate(buf []byte, o ImageOptions) (Image, error) { 218 | if o.Rotate == 0 { 219 | return Image{}, NewError("Missing required param: rotate", http.StatusBadRequest) 220 | } 221 | 222 | opts := BimgOptions(o) 223 | return Process(buf, opts) 224 | } 225 | 226 | func AutoRotate(buf []byte, o ImageOptions) (out Image, err error) { 227 | defer func() { 228 | if r := recover(); r != nil { 229 | switch value := r.(type) { 230 | case error: 231 | err = value 232 | case string: 233 | err = errors.New(value) 234 | default: 235 | err = errors.New("libvips internal error") 236 | } 237 | out = Image{} 238 | } 239 | }() 240 | 241 | // Resize image via bimg 242 | ibuf, err := bimg.NewImage(buf).AutoRotate() 243 | if err != nil { 244 | return Image{}, err 245 | } 246 | 247 | mime := GetImageMimeType(bimg.DetermineImageType(ibuf)) 248 | return Image{Body: ibuf, Mime: mime}, nil 249 | } 250 | 251 | func Flip(buf []byte, o ImageOptions) (Image, error) { 252 | opts := BimgOptions(o) 253 | opts.Flip = true 254 | return Process(buf, opts) 255 | } 256 | 257 | func Flop(buf []byte, o ImageOptions) (Image, error) { 258 | opts := BimgOptions(o) 259 | opts.Flop = true 260 | return Process(buf, opts) 261 | } 262 | 263 | func Thumbnail(buf []byte, o ImageOptions) (Image, error) { 264 | if o.Width == 0 && o.Height == 0 { 265 | return Image{}, NewError("Missing required params: width or height", http.StatusBadRequest) 266 | } 267 | 268 | return Process(buf, BimgOptions(o)) 269 | } 270 | 271 | func Zoom(buf []byte, o ImageOptions) (Image, error) { 272 | if o.Factor == 0 { 273 | return Image{}, NewError("Missing required param: factor", http.StatusBadRequest) 274 | } 275 | 276 | opts := BimgOptions(o) 277 | 278 | if o.Top > 0 || o.Left > 0 { 279 | if o.AreaWidth == 0 && o.AreaHeight == 0 { 280 | return Image{}, NewError("Missing required params: areawidth, areaheight", http.StatusBadRequest) 281 | } 282 | 283 | opts.Top = o.Top 284 | opts.Left = o.Left 285 | opts.AreaWidth = o.AreaWidth 286 | opts.AreaHeight = o.AreaHeight 287 | 288 | if o.IsDefinedField.NoCrop { 289 | opts.Crop = !o.NoCrop 290 | } 291 | } 292 | 293 | opts.Zoom = o.Factor 294 | return Process(buf, opts) 295 | } 296 | 297 | func Convert(buf []byte, o ImageOptions) (Image, error) { 298 | if o.Type == "" { 299 | return Image{}, NewError("Missing required param: type", http.StatusBadRequest) 300 | } 301 | if ImageType(o.Type) == bimg.UNKNOWN { 302 | return Image{}, NewError("Invalid image type: "+o.Type, http.StatusBadRequest) 303 | } 304 | opts := BimgOptions(o) 305 | 306 | return Process(buf, opts) 307 | } 308 | 309 | func Watermark(buf []byte, o ImageOptions) (Image, error) { 310 | if o.Text == "" { 311 | return Image{}, NewError("Missing required param: text", http.StatusBadRequest) 312 | } 313 | 314 | opts := BimgOptions(o) 315 | opts.Watermark.DPI = o.DPI 316 | opts.Watermark.Text = o.Text 317 | opts.Watermark.Font = o.Font 318 | opts.Watermark.Margin = o.Margin 319 | opts.Watermark.Width = o.TextWidth 320 | opts.Watermark.Opacity = o.Opacity 321 | opts.Watermark.NoReplicate = o.NoReplicate 322 | 323 | if len(o.Color) > 2 { 324 | opts.Watermark.Background = bimg.Color{R: o.Color[0], G: o.Color[1], B: o.Color[2]} 325 | } 326 | 327 | return Process(buf, opts) 328 | } 329 | 330 | func WatermarkImage(buf []byte, o ImageOptions) (Image, error) { 331 | if o.Image == "" { 332 | return Image{}, NewError("Missing required param: image", http.StatusBadRequest) 333 | } 334 | response, err := http.Get(o.Image) 335 | if err != nil { 336 | return Image{}, NewError(fmt.Sprintf("Unable to retrieve watermark image. %s", o.Image), http.StatusBadRequest) 337 | } 338 | defer func() { 339 | _ = response.Body.Close() 340 | }() 341 | 342 | bodyReader := io.LimitReader(response.Body, 1e6) 343 | 344 | imageBuf, err := ioutil.ReadAll(bodyReader) 345 | if len(imageBuf) == 0 { 346 | errMessage := "Unable to read watermark image" 347 | 348 | if err != nil { 349 | errMessage = fmt.Sprintf("%s. %s", errMessage, err.Error()) 350 | } 351 | 352 | return Image{}, NewError(errMessage, http.StatusBadRequest) 353 | } 354 | 355 | opts := BimgOptions(o) 356 | opts.WatermarkImage.Left = o.Left 357 | opts.WatermarkImage.Top = o.Top 358 | opts.WatermarkImage.Buf = imageBuf 359 | opts.WatermarkImage.Opacity = o.Opacity 360 | 361 | return Process(buf, opts) 362 | } 363 | 364 | func GaussianBlur(buf []byte, o ImageOptions) (Image, error) { 365 | if o.Sigma == 0 && o.MinAmpl == 0 { 366 | return Image{}, NewError("Missing required param: sigma or minampl", http.StatusBadRequest) 367 | } 368 | opts := BimgOptions(o) 369 | return Process(buf, opts) 370 | } 371 | 372 | func Pipeline(buf []byte, o ImageOptions) (Image, error) { 373 | if len(o.Operations) == 0 { 374 | return Image{}, NewError("Missing or invalid pipeline operations JSON", http.StatusBadRequest) 375 | } 376 | if len(o.Operations) > 10 { 377 | return Image{}, NewError("Maximum allowed pipeline operations exceeded", http.StatusBadRequest) 378 | } 379 | 380 | // Validate and built operations 381 | for i, operation := range o.Operations { 382 | // Validate supported operation name 383 | var exists bool 384 | if operation.Operation, exists = OperationsMap[operation.Name]; !exists { 385 | return Image{}, NewError(fmt.Sprintf("Unsupported operation name: %s", operation.Name), http.StatusBadRequest) 386 | } 387 | 388 | // Parse and construct operation options 389 | var err error 390 | operation.ImageOptions, err = buildParamsFromOperation(operation) 391 | if err != nil { 392 | return Image{}, err 393 | } 394 | 395 | // Mutate list by value 396 | o.Operations[i] = operation 397 | } 398 | 399 | var image Image 400 | var err error 401 | 402 | // Reduce image by running multiple operations 403 | image = Image{Body: buf} 404 | for _, operation := range o.Operations { 405 | var curImage Image 406 | curImage, err = operation.Operation(image.Body, operation.ImageOptions) 407 | if err != nil && !operation.IgnoreFailure { 408 | return Image{}, err 409 | } 410 | if operation.IgnoreFailure { 411 | err = nil 412 | } 413 | if err == nil { 414 | image = curImage 415 | } 416 | } 417 | 418 | return image, err 419 | } 420 | 421 | func Process(buf []byte, opts bimg.Options) (out Image, err error) { 422 | defer func() { 423 | if r := recover(); r != nil { 424 | switch value := r.(type) { 425 | case error: 426 | err = value 427 | case string: 428 | err = errors.New(value) 429 | default: 430 | err = errors.New("libvips internal error") 431 | } 432 | out = Image{} 433 | } 434 | }() 435 | 436 | // Resize image via bimg 437 | ibuf, err := bimg.Resize(buf, opts) 438 | 439 | // Handle specific type encode errors gracefully 440 | if err != nil && strings.Contains(err.Error(), "encode") && (opts.Type == bimg.WEBP || opts.Type == bimg.HEIF) { 441 | // Always fallback to JPEG 442 | opts.Type = bimg.JPEG 443 | ibuf, err = bimg.Resize(buf, opts) 444 | } 445 | 446 | if err != nil { 447 | return Image{}, err 448 | } 449 | 450 | mime := GetImageMimeType(bimg.DetermineImageType(ibuf)) 451 | return Image{Body: ibuf, Mime: mime}, nil 452 | } 453 | -------------------------------------------------------------------------------- /image_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | ) 7 | 8 | func TestImageResize(t *testing.T) { 9 | t.Run("Width and Height defined", func(t *testing.T) { 10 | opts := ImageOptions{Width: 300, Height: 300} 11 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 12 | 13 | img, err := Resize(buf, opts) 14 | if err != nil { 15 | t.Errorf("Cannot process image: %s", err) 16 | } 17 | if img.Mime != "image/jpeg" { 18 | t.Error("Invalid image MIME type") 19 | } 20 | if assertSize(img.Body, opts.Width, opts.Height) != nil { 21 | t.Errorf("Invalid image size, expected: %dx%d", opts.Width, opts.Height) 22 | } 23 | }) 24 | 25 | t.Run("Width defined", func(t *testing.T) { 26 | opts := ImageOptions{Width: 300} 27 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 28 | 29 | img, err := Resize(buf, opts) 30 | if err != nil { 31 | t.Errorf("Cannot process image: %s", err) 32 | } 33 | if img.Mime != "image/jpeg" { 34 | t.Error("Invalid image MIME type") 35 | } 36 | if err := assertSize(img.Body, 300, 404); err != nil { 37 | t.Error(err) 38 | } 39 | }) 40 | 41 | t.Run("Width defined with NoCrop=false", func(t *testing.T) { 42 | opts := ImageOptions{Width: 300, NoCrop: false, IsDefinedField: IsDefinedField{NoCrop: true}} 43 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 44 | 45 | img, err := Resize(buf, opts) 46 | if err != nil { 47 | t.Errorf("Cannot process image: %s", err) 48 | } 49 | if img.Mime != "image/jpeg" { 50 | t.Error("Invalid image MIME type") 51 | } 52 | 53 | // The original image is 550x740 54 | if err := assertSize(img.Body, 300, 740); err != nil { 55 | t.Error(err) 56 | } 57 | }) 58 | 59 | t.Run("Width defined with NoCrop=true", func(t *testing.T) { 60 | opts := ImageOptions{Width: 300, NoCrop: true, IsDefinedField: IsDefinedField{NoCrop: true}} 61 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 62 | 63 | img, err := Resize(buf, opts) 64 | if err != nil { 65 | t.Errorf("Cannot process image: %s", err) 66 | } 67 | if img.Mime != "image/jpeg" { 68 | t.Error("Invalid image MIME type") 69 | } 70 | 71 | // The original image is 550x740 72 | if err := assertSize(img.Body, 300, 404); err != nil { 73 | t.Error(err) 74 | } 75 | }) 76 | 77 | } 78 | 79 | func TestImageFit(t *testing.T) { 80 | opts := ImageOptions{Width: 300, Height: 300} 81 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 82 | 83 | img, err := Fit(buf, opts) 84 | if err != nil { 85 | t.Errorf("Cannot process image: %s", err) 86 | } 87 | if img.Mime != "image/jpeg" { 88 | t.Error("Invalid image MIME type") 89 | } 90 | // 550x740 -> 222.9x300 91 | if assertSize(img.Body, 223, 300) != nil { 92 | t.Errorf("Invalid image size, expected: %dx%d", opts.Width, opts.Height) 93 | } 94 | } 95 | 96 | func TestImageAutoRotate(t *testing.T) { 97 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 98 | img, err := AutoRotate(buf, ImageOptions{}) 99 | if err != nil { 100 | t.Errorf("Cannot process image: %s", err) 101 | } 102 | if img.Mime != "image/jpeg" { 103 | t.Error("Invalid image MIME type") 104 | } 105 | if assertSize(img.Body, 550, 740) != nil { 106 | t.Errorf("Invalid image size, expected: %dx%d", 550, 740) 107 | } 108 | } 109 | 110 | func TestImagePipelineOperations(t *testing.T) { 111 | width, height := 300, 260 112 | 113 | operations := PipelineOperations{ 114 | PipelineOperation{ 115 | Name: "crop", 116 | Params: map[string]interface{}{ 117 | "width": width, 118 | "height": height, 119 | }, 120 | }, 121 | PipelineOperation{ 122 | Name: "convert", 123 | Params: map[string]interface{}{ 124 | "type": "webp", 125 | }, 126 | }, 127 | } 128 | 129 | opts := ImageOptions{Operations: operations} 130 | buf, _ := ioutil.ReadAll(readFile("imaginary.jpg")) 131 | 132 | img, err := Pipeline(buf, opts) 133 | if err != nil { 134 | t.Errorf("Cannot process image: %s", err) 135 | } 136 | if img.Mime != "image/webp" { 137 | t.Error("Invalid image MIME type") 138 | } 139 | if assertSize(img.Body, width, height) != nil { 140 | t.Errorf("Invalid image size, expected: %dx%d", width, height) 141 | } 142 | } 143 | 144 | func TestCalculateDestinationFitDimension(t *testing.T) { 145 | cases := []struct { 146 | // Image 147 | imageWidth int 148 | imageHeight int 149 | 150 | // User parameter 151 | optionWidth int 152 | optionHeight int 153 | 154 | // Expect 155 | fitWidth int 156 | fitHeight int 157 | }{ 158 | 159 | // Leading Width 160 | {1280, 1000, 710, 9999, 710, 555}, 161 | {1279, 1000, 710, 9999, 710, 555}, 162 | {900, 500, 312, 312, 312, 173}, // rounding down 163 | {900, 500, 313, 313, 313, 174}, // rounding up 164 | 165 | // Leading height 166 | {1299, 2000, 710, 999, 649, 999}, 167 | {1500, 2000, 710, 999, 710, 947}, 168 | } 169 | 170 | for _, tc := range cases { 171 | fitWidth, fitHeight := calculateDestinationFitDimension(tc.imageWidth, tc.imageHeight, tc.optionWidth, tc.optionHeight) 172 | if fitWidth != tc.fitWidth || fitHeight != tc.fitHeight { 173 | t.Errorf( 174 | "Fit dimensions calculation failure\nExpected : %d/%d (width/height)\nActual : %d/%d (width/height)\n%+v", 175 | tc.fitWidth, tc.fitHeight, fitWidth, fitHeight, tc, 176 | ) 177 | } 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /imaginary.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/url" 9 | "os" 10 | "runtime" 11 | d "runtime/debug" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/h2non/bimg" 17 | ) 18 | 19 | var ( 20 | aAddr = flag.String("a", "", "Bind address") 21 | aPort = flag.Int("p", 8088, "Port to listen") 22 | aVers = flag.Bool("v", false, "Show version") 23 | aVersl = flag.Bool("version", false, "Show version") 24 | aHelp = flag.Bool("h", false, "Show help") 25 | aHelpl = flag.Bool("help", false, "Show help") 26 | aPathPrefix = flag.String("path-prefix", "/", "Url path prefix to listen to") 27 | aCors = flag.Bool("cors", false, "Enable CORS support") 28 | aGzip = flag.Bool("gzip", false, "Enable gzip compression (deprecated)") 29 | aAuthForwarding = flag.Bool("enable-auth-forwarding", false, "Forwards X-Forward-Authorization or Authorization header to the image source server. -enable-url-source flag must be defined. Tip: secure your server from public access to prevent attack vectors") 30 | aEnableURLSource = flag.Bool("enable-url-source", false, "Enable remote HTTP URL image source processing") 31 | aEnablePlaceholder = flag.Bool("enable-placeholder", false, "Enable image response placeholder to be used in case of error") 32 | aEnableURLSignature = flag.Bool("enable-url-signature", false, "Enable URL signature (URL-safe Base64-encoded HMAC digest)") 33 | aURLSignatureKey = flag.String("url-signature-key", "", "The URL signature key (32 characters minimum)") 34 | aAllowedOrigins = flag.String("allowed-origins", "", "Restrict remote image source processing to certain origins (separated by commas). Note: Origins are validated against host *AND* path.") 35 | aMaxAllowedSize = flag.Int("max-allowed-size", 0, "Restrict maximum size of http image source (in bytes)") 36 | aMaxAllowedPixels = flag.Float64("max-allowed-resolution", 18.0, "Restrict maximum resolution of the image (in megapixels)") 37 | aKey = flag.String("key", "", "Define API key for authorization") 38 | aMount = flag.String("mount", "", "Mount server local directory") 39 | aCertFile = flag.String("certfile", "", "TLS certificate file path") 40 | aKeyFile = flag.String("keyfile", "", "TLS private key file path") 41 | aAuthorization = flag.String("authorization", "", "Defines a constant Authorization header value passed to all the image source servers. -enable-url-source flag must be defined. This overwrites authorization headers forwarding behavior via X-Forward-Authorization") 42 | aForwardHeaders = flag.String("forward-headers", "", "Forwards custom headers to the image source server. -enable-url-source flag must be defined.") 43 | aPlaceholder = flag.String("placeholder", "", "Image path to image custom placeholder to be used in case of error. Recommended minimum image size is: 1200x1200") 44 | aPlaceholderStatus = flag.Int("placeholder-status", 0, "HTTP status returned when use -placeholder flag") 45 | aDisableEndpoints = flag.String("disable-endpoints", "", "Comma separated endpoints to disable. E.g: form,crop,rotate,health") 46 | aHTTPCacheTTL = flag.Int("http-cache-ttl", -1, "The TTL in seconds") 47 | aReadTimeout = flag.Int("http-read-timeout", 60, "HTTP read timeout in seconds") 48 | aWriteTimeout = flag.Int("http-write-timeout", 60, "HTTP write timeout in seconds") 49 | aConcurrency = flag.Int("concurrency", 0, "Throttle concurrency limit per second") 50 | aBurst = flag.Int("burst", 100, "Throttle burst max cache size") 51 | aMRelease = flag.Int("mrelease", 30, "OS memory release interval in seconds") 52 | aCpus = flag.Int("cpus", runtime.GOMAXPROCS(-1), "Number of cpu cores to use") 53 | aLogLevel = flag.String("log-level", "info", "Define log level for http-server. E.g: info,warning,error") 54 | aReturnSize = flag.Bool("return-size", false, "Return the image size in the HTTP headers") 55 | ) 56 | 57 | const usage = `imaginary %s 58 | 59 | Usage: 60 | imaginary -p 80 61 | imaginary -cors 62 | imaginary -concurrency 10 63 | imaginary -path-prefix /api/v1 64 | imaginary -enable-url-source 65 | imaginary -disable-endpoints form,health,crop,rotate 66 | imaginary -enable-url-source -allowed-origins http://localhost,http://server.com 67 | imaginary -enable-url-source -enable-auth-forwarding 68 | imaginary -enable-url-source -authorization "Basic AwDJdL2DbwrD==" 69 | imaginary -enable-placeholder 70 | imaginary -enable-url-source -placeholder ./placeholder.jpg 71 | imaginary -enable-url-signature -url-signature-key 4f46feebafc4b5e988f131c4ff8b5997 72 | imaginary -enable-url-source -forward-headers X-Custom,X-Token 73 | imaginary -h | -help 74 | imaginary -v | -version 75 | 76 | Options: 77 | 78 | -a Bind address [default: *] 79 | -p Bind port [default: 8088] 80 | -h, -help Show help 81 | -v, -version Show version 82 | -path-prefix Url path prefix to listen to [default: "/"] 83 | -cors Enable CORS support [default: false] 84 | -gzip Enable gzip compression (deprecated) [default: false] 85 | -disable-endpoints Comma separated endpoints to disable. E.g: form,crop,rotate,health [default: ""] 86 | -key Define API key for authorization 87 | -mount Mount server local directory 88 | -http-cache-ttl The TTL in seconds. Adds caching headers to locally served files. 89 | -http-read-timeout HTTP read timeout in seconds [default: 30] 90 | -http-write-timeout HTTP write timeout in seconds [default: 30] 91 | -enable-url-source Enable remote HTTP URL image source processing 92 | -enable-placeholder Enable image response placeholder to be used in case of error [default: false] 93 | -enable-auth-forwarding Forwards X-Forward-Authorization or Authorization header to the image source server. -enable-url-source flag must be defined. Tip: secure your server from public access to prevent attack vectors 94 | -forward-headers Forwards custom headers to the image source server. -enable-url-source flag must be defined. 95 | -enable-url-signature Enable URL signature (URL-safe Base64-encoded HMAC digest) [default: false] 96 | -url-signature-key The URL signature key (32 characters minimum) 97 | -allowed-origins Restrict remote image source processing to certain origins (separated by commas) 98 | -max-allowed-size Restrict maximum size of http image source (in bytes) 99 | -max-allowed-resolution Restrict maximum resolution of the image [default: 18.0] 100 | -certfile TLS certificate file path 101 | -keyfile TLS private key file path 102 | -authorization Defines a constant Authorization header value passed to all the image source servers. -enable-url-source flag must be defined. This overwrites authorization headers forwarding behavior via X-Forward-Authorization 103 | -placeholder Image path to image custom placeholder to be used in case of error. Recommended minimum image size is: 1200x1200 104 | -placeholder-status HTTP status returned when use -placeholder flag 105 | -concurrency Throttle concurrency limit per second [default: disabled] 106 | -burst Throttle burst max cache size [default: 100] 107 | -mrelease OS memory release interval in seconds [default: 30] 108 | -cpus Number of used cpu cores. 109 | (default for current machine is %d cores) 110 | -log-level Set log level for http-server. E.g: info,warning,error [default: info]. 111 | Or can use the environment variable GOLANG_LOG=info. 112 | -return-size Return the image size with X-Width and X-Height HTTP header. [default: disabled]. 113 | ` 114 | 115 | type URLSignature struct { 116 | Key string 117 | } 118 | 119 | func main() { 120 | flag.Usage = func() { 121 | _, _ = fmt.Fprintf(os.Stderr, usage, Version, runtime.NumCPU()) 122 | } 123 | flag.Parse() 124 | 125 | if *aHelp || *aHelpl { 126 | showUsage() 127 | } 128 | if *aVers || *aVersl { 129 | showVersion() 130 | } 131 | 132 | // Only required in Go < 1.5 133 | runtime.GOMAXPROCS(*aCpus) 134 | 135 | port := getPort(*aPort) 136 | urlSignature := getURLSignature(*aURLSignatureKey) 137 | 138 | opts := ServerOptions{ 139 | Port: port, 140 | Address: *aAddr, 141 | CORS: *aCors, 142 | AuthForwarding: *aAuthForwarding, 143 | EnableURLSource: *aEnableURLSource, 144 | EnablePlaceholder: *aEnablePlaceholder, 145 | EnableURLSignature: *aEnableURLSignature, 146 | URLSignatureKey: urlSignature.Key, 147 | PathPrefix: *aPathPrefix, 148 | APIKey: *aKey, 149 | Concurrency: *aConcurrency, 150 | Burst: *aBurst, 151 | Mount: *aMount, 152 | CertFile: *aCertFile, 153 | KeyFile: *aKeyFile, 154 | Placeholder: *aPlaceholder, 155 | PlaceholderStatus: *aPlaceholderStatus, 156 | HTTPCacheTTL: *aHTTPCacheTTL, 157 | HTTPReadTimeout: *aReadTimeout, 158 | HTTPWriteTimeout: *aWriteTimeout, 159 | Authorization: *aAuthorization, 160 | ForwardHeaders: parseForwardHeaders(*aForwardHeaders), 161 | AllowedOrigins: parseOrigins(*aAllowedOrigins), 162 | MaxAllowedSize: *aMaxAllowedSize, 163 | MaxAllowedPixels: *aMaxAllowedPixels, 164 | LogLevel: getLogLevel(*aLogLevel), 165 | ReturnSize: *aReturnSize, 166 | } 167 | 168 | // Show warning if gzip flag is passed 169 | if *aGzip { 170 | fmt.Println("warning: -gzip flag is deprecated and will not have effect") 171 | } 172 | 173 | // Create a memory release goroutine 174 | if *aMRelease > 0 { 175 | memoryRelease(*aMRelease) 176 | } 177 | 178 | // Check if the mount directory exists, if present 179 | if *aMount != "" { 180 | checkMountDirectory(*aMount) 181 | } 182 | 183 | // Validate HTTP cache param, if present 184 | if *aHTTPCacheTTL != -1 { 185 | checkHTTPCacheTTL(*aHTTPCacheTTL) 186 | } 187 | 188 | // Parse endpoint names to disabled, if present 189 | if *aDisableEndpoints != "" { 190 | opts.Endpoints = parseEndpoints(*aDisableEndpoints) 191 | } 192 | 193 | // Read placeholder image, if required 194 | if *aPlaceholder != "" { 195 | buf, err := ioutil.ReadFile(*aPlaceholder) 196 | if err != nil { 197 | exitWithError("cannot start the server: %s", err) 198 | } 199 | 200 | imageType := bimg.DetermineImageType(buf) 201 | if !bimg.IsImageTypeSupportedByVips(imageType).Load { 202 | exitWithError("Placeholder image type is not supported. Only JPEG, PNG or WEBP are supported") 203 | } 204 | 205 | opts.PlaceholderImage = buf 206 | } else if *aEnablePlaceholder { 207 | // Expose default placeholder 208 | opts.PlaceholderImage = placeholder 209 | } 210 | 211 | // Check URL signature key, if required 212 | if *aEnableURLSignature { 213 | if urlSignature.Key == "" { 214 | exitWithError("URL signature key is required") 215 | } 216 | 217 | if len(urlSignature.Key) < 32 { 218 | exitWithError("URL signature key must be a minimum of 32 characters") 219 | } 220 | } 221 | 222 | debug("imaginary server listening on port :%d/%s", opts.Port, strings.TrimPrefix(opts.PathPrefix, "/")) 223 | 224 | // Load image source providers 225 | LoadSources(opts) 226 | 227 | // Start the server 228 | Server(opts) 229 | } 230 | 231 | func getPort(port int) int { 232 | if portEnv := os.Getenv("PORT"); portEnv != "" { 233 | newPort, _ := strconv.Atoi(portEnv) 234 | if newPort > 0 { 235 | port = newPort 236 | } 237 | } 238 | return port 239 | } 240 | 241 | func getURLSignature(key string) URLSignature { 242 | if keyEnv := os.Getenv("URL_SIGNATURE_KEY"); keyEnv != "" { 243 | key = keyEnv 244 | } 245 | 246 | return URLSignature{key} 247 | } 248 | 249 | func getLogLevel(logLevel string) string { 250 | if logLevelEnv := os.Getenv("GOLANG_LOG"); logLevelEnv != "" { 251 | logLevel = logLevelEnv 252 | } 253 | return logLevel 254 | } 255 | 256 | func showUsage() { 257 | flag.Usage() 258 | os.Exit(1) 259 | } 260 | 261 | func showVersion() { 262 | fmt.Println(Version) 263 | os.Exit(1) 264 | } 265 | 266 | func checkMountDirectory(path string) { 267 | src, err := os.Stat(path) 268 | if err != nil { 269 | exitWithError("error while mounting directory: %s", err) 270 | } 271 | if !src.IsDir() { 272 | exitWithError("mount path is not a directory: %s", path) 273 | } 274 | if path == "/" { 275 | exitWithError("cannot mount root directory for security reasons") 276 | } 277 | } 278 | 279 | func checkHTTPCacheTTL(ttl int) { 280 | if ttl < 0 || ttl > 31556926 { 281 | exitWithError("The -http-cache-ttl flag only accepts a value from 0 to 31556926") 282 | } 283 | 284 | if ttl == 0 { 285 | debug("Adding HTTP cache control headers set to prevent caching.") 286 | } 287 | } 288 | 289 | func parseForwardHeaders(forwardHeaders string) []string { 290 | var headers []string 291 | if forwardHeaders == "" { 292 | return headers 293 | } 294 | 295 | for _, header := range strings.Split(forwardHeaders, ",") { 296 | if norm := strings.TrimSpace(header); norm != "" { 297 | headers = append(headers, norm) 298 | } 299 | } 300 | return headers 301 | } 302 | 303 | func parseOrigins(origins string) []*url.URL { 304 | var urls []*url.URL 305 | if origins == "" { 306 | return urls 307 | } 308 | for _, origin := range strings.Split(origins, ",") { 309 | u, err := url.Parse(origin) 310 | if err != nil { 311 | continue 312 | } 313 | 314 | if u.Path != "" { 315 | var lastChar = u.Path[len(u.Path)-1:] 316 | if lastChar == "*" { 317 | u.Path = strings.TrimSuffix(u.Path, "*") 318 | } else if lastChar != "/" { 319 | u.Path += "/" 320 | } 321 | } 322 | 323 | urls = append(urls, u) 324 | } 325 | return urls 326 | } 327 | 328 | func parseEndpoints(input string) Endpoints { 329 | var endpoints Endpoints 330 | for _, endpoint := range strings.Split(input, ",") { 331 | endpoint = strings.ToLower(strings.TrimSpace(endpoint)) 332 | if endpoint != "" { 333 | endpoints = append(endpoints, endpoint) 334 | } 335 | } 336 | return endpoints 337 | } 338 | 339 | func memoryRelease(interval int) { 340 | ticker := time.NewTicker(time.Duration(interval) * time.Second) 341 | go func() { 342 | for range ticker.C { 343 | debug("FreeOSMemory()") 344 | d.FreeOSMemory() 345 | } 346 | }() 347 | } 348 | 349 | func exitWithError(format string, args ...interface{}) { 350 | _, _ = fmt.Fprintf(os.Stderr, format+"\n", args) 351 | os.Exit(1) 352 | } 353 | 354 | func debug(msg string, values ...interface{}) { 355 | debug := os.Getenv("DEBUG") 356 | if debug == "imaginary" || debug == "*" { 357 | log.Printf(msg, values...) 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const formatPattern = "%s - - [%s] \"%s\" %d %d %.4f\n" 12 | 13 | // LogRecord implements an Apache-compatible HTTP logging 14 | type LogRecord struct { 15 | http.ResponseWriter 16 | status int 17 | responseBytes int64 18 | ip string 19 | method, uri, protocol string 20 | time time.Time 21 | elapsedTime time.Duration 22 | } 23 | 24 | // Log writes a log entry in the passed io.Writer stream 25 | func (r *LogRecord) Log(out io.Writer) { 26 | timeFormat := r.time.Format("02/Jan/2006 15:04:05") 27 | request := fmt.Sprintf("%s %s %s", r.method, r.uri, r.protocol) 28 | _, _ = fmt.Fprintf(out, formatPattern, r.ip, timeFormat, request, r.status, r.responseBytes, r.elapsedTime.Seconds()) 29 | } 30 | 31 | // Write acts like a proxy passing the given bytes buffer to the ResponseWritter 32 | // and additionally counting the passed amount of bytes for logging usage. 33 | func (r *LogRecord) Write(p []byte) (int, error) { 34 | written, err := r.ResponseWriter.Write(p) 35 | r.responseBytes += int64(written) 36 | return written, err 37 | } 38 | 39 | // WriteHeader calls ResponseWriter.WriteHeader() and sets the status code 40 | func (r *LogRecord) WriteHeader(status int) { 41 | r.status = status 42 | r.ResponseWriter.WriteHeader(status) 43 | } 44 | 45 | // LogHandler maps the HTTP handler with a custom io.Writer compatible stream 46 | type LogHandler struct { 47 | handler http.Handler 48 | io io.Writer 49 | logLevel string 50 | } 51 | 52 | // NewLog creates a new logger 53 | func NewLog(handler http.Handler, io io.Writer, logLevel string) http.Handler { 54 | return &LogHandler{handler, io, logLevel} 55 | } 56 | 57 | // Implements the required method as standard HTTP handler, serving the request. 58 | func (h *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 59 | clientIP := r.RemoteAddr 60 | if colon := strings.LastIndex(clientIP, ":"); colon != -1 { 61 | clientIP = clientIP[:colon] 62 | } 63 | 64 | record := &LogRecord{ 65 | ResponseWriter: w, 66 | ip: clientIP, 67 | time: time.Time{}, 68 | method: r.Method, 69 | uri: r.RequestURI, 70 | protocol: r.Proto, 71 | status: http.StatusOK, 72 | elapsedTime: time.Duration(0), 73 | } 74 | 75 | startTime := time.Now() 76 | h.handler.ServeHTTP(record, r) 77 | finishTime := time.Now() 78 | 79 | record.time = finishTime.UTC() 80 | record.elapsedTime = finishTime.Sub(startTime) 81 | 82 | switch h.logLevel { 83 | case "error": 84 | if record.status >= http.StatusInternalServerError { 85 | record.Log(h.io) 86 | } 87 | case "warning": 88 | if record.status >= http.StatusBadRequest { 89 | record.Log(h.io) 90 | } 91 | case "info": 92 | record.Log(h.io) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /log_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | type fakeWriter func([]byte) (int, error) 11 | 12 | func (fake fakeWriter) Write(buf []byte) (int, error) { 13 | return fake(buf) 14 | } 15 | 16 | func TestLogInfo(t *testing.T) { 17 | var buf []byte 18 | writer := fakeWriter(func(b []byte) (int, error) { 19 | buf = b 20 | return 0, nil 21 | }) 22 | 23 | noopHandler := func(w http.ResponseWriter, r *http.Request) {} 24 | log := NewLog(http.HandlerFunc(noopHandler), writer, "info") 25 | 26 | ts := httptest.NewServer(log) 27 | defer ts.Close() 28 | 29 | _, err := http.Get(ts.URL) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | data := string(buf) 35 | if strings.Contains(data, http.MethodGet) == false || 36 | strings.Contains(data, "HTTP/1.1") == false || 37 | strings.Contains(data, " 200 ") == false { 38 | t.Fatalf("Invalid log output: %s", data) 39 | } 40 | } 41 | 42 | func TestLogError(t *testing.T) { 43 | var buf []byte 44 | writer := fakeWriter(func(b []byte) (int, error) { 45 | buf = b 46 | return 0, nil 47 | }) 48 | 49 | noopHandler := func(w http.ResponseWriter, r *http.Request) {} 50 | log := NewLog(http.HandlerFunc(noopHandler), writer, "error") 51 | 52 | ts := httptest.NewServer(log) 53 | defer ts.Close() 54 | 55 | _, err := http.Get(ts.URL) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | 60 | data := string(buf) 61 | if data != "" { 62 | t.Fatalf("Invalid log output: %s", data) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "fmt" 8 | "net/http" 9 | "strings" 10 | "time" 11 | 12 | "github.com/h2non/bimg" 13 | "github.com/rs/cors" 14 | "github.com/throttled/throttled/v2" 15 | "github.com/throttled/throttled/v2/store/memstore" 16 | ) 17 | 18 | func Middleware(fn func(http.ResponseWriter, *http.Request), o ServerOptions) http.Handler { 19 | next := http.Handler(http.HandlerFunc(fn)) 20 | 21 | if len(o.Endpoints) > 0 { 22 | next = filterEndpoint(next, o) 23 | } 24 | if o.Concurrency > 0 { 25 | next = throttle(next, o) 26 | } 27 | if o.CORS { 28 | next = cors.Default().Handler(next) 29 | } 30 | if o.APIKey != "" { 31 | next = authorizeClient(next, o) 32 | } 33 | if o.HTTPCacheTTL >= 0 { 34 | next = setCacheHeaders(next, o.HTTPCacheTTL) 35 | } 36 | 37 | return validate(defaultHeaders(next), o) 38 | } 39 | 40 | func ImageMiddleware(o ServerOptions) func(Operation) http.Handler { 41 | return func(fn Operation) http.Handler { 42 | handler := validateImage(Middleware(imageController(o, fn), o), o) 43 | 44 | if o.EnableURLSignature { 45 | return validateURLSignature(handler, o) 46 | } 47 | 48 | return handler 49 | } 50 | } 51 | 52 | func filterEndpoint(next http.Handler, o ServerOptions) http.Handler { 53 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | if o.Endpoints.IsValid(r) { 55 | next.ServeHTTP(w, r) 56 | return 57 | } 58 | ErrorReply(r, w, ErrNotImplemented, o) 59 | }) 60 | } 61 | 62 | func throttleError(err error) http.Handler { 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | http.Error(w, "throttle error: "+err.Error(), http.StatusInternalServerError) 65 | }) 66 | } 67 | 68 | func throttle(next http.Handler, o ServerOptions) http.Handler { 69 | store, err := memstore.New(65536) 70 | if err != nil { 71 | return throttleError(err) 72 | } 73 | 74 | quota := throttled.RateQuota{MaxRate: throttled.PerSec(o.Concurrency), MaxBurst: o.Burst} 75 | rateLimiter, err := throttled.NewGCRARateLimiter(store, quota) 76 | if err != nil { 77 | return throttleError(err) 78 | } 79 | 80 | httpRateLimiter := throttled.HTTPRateLimiter{ 81 | RateLimiter: rateLimiter, 82 | VaryBy: &throttled.VaryBy{Method: true}, 83 | } 84 | 85 | return httpRateLimiter.RateLimit(next) 86 | } 87 | 88 | func validate(next http.Handler, o ServerOptions) http.Handler { 89 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 | if r.Method != http.MethodGet && r.Method != http.MethodPost { 91 | ErrorReply(r, w, ErrMethodNotAllowed, o) 92 | return 93 | } 94 | 95 | next.ServeHTTP(w, r) 96 | }) 97 | } 98 | 99 | func validateImage(next http.Handler, o ServerOptions) http.Handler { 100 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 101 | path := r.URL.Path 102 | if r.Method == http.MethodGet && isPublicPath(path) { 103 | next.ServeHTTP(w, r) 104 | return 105 | } 106 | 107 | if r.Method == http.MethodGet && o.Mount == "" && !o.EnableURLSource { 108 | ErrorReply(r, w, ErrGetMethodNotAllowed, o) 109 | return 110 | } 111 | 112 | next.ServeHTTP(w, r) 113 | }) 114 | } 115 | 116 | func authorizeClient(next http.Handler, o ServerOptions) http.Handler { 117 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 | key := r.Header.Get("API-Key") 119 | if key == "" { 120 | key = r.URL.Query().Get("key") 121 | } 122 | 123 | if key != o.APIKey { 124 | ErrorReply(r, w, ErrInvalidAPIKey, o) 125 | return 126 | } 127 | 128 | next.ServeHTTP(w, r) 129 | }) 130 | } 131 | 132 | func defaultHeaders(next http.Handler) http.Handler { 133 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 | w.Header().Set("Server", fmt.Sprintf("imaginary %s (bimg %s)", Version, bimg.Version)) 135 | next.ServeHTTP(w, r) 136 | }) 137 | } 138 | 139 | func setCacheHeaders(next http.Handler, ttl int) http.Handler { 140 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 | defer next.ServeHTTP(w, r) 142 | 143 | if r.Method != http.MethodGet || isPublicPath(r.URL.Path) { 144 | return 145 | } 146 | 147 | ttlDiff := time.Duration(ttl) * time.Second 148 | expires := time.Now().Add(ttlDiff) 149 | 150 | w.Header().Add("Expires", strings.Replace(expires.Format(time.RFC1123), "UTC", "GMT", -1)) 151 | w.Header().Add("Cache-Control", getCacheControl(ttl)) 152 | }) 153 | } 154 | 155 | func getCacheControl(ttl int) string { 156 | if ttl == 0 { 157 | return "private, no-cache, no-store, must-revalidate" 158 | } 159 | return fmt.Sprintf("public, s-maxage=%d, max-age=%d, no-transform", ttl, ttl) 160 | } 161 | 162 | func isPublicPath(path string) bool { 163 | return path == "/" || path == "/health" || path == "/form" 164 | } 165 | 166 | func validateURLSignature(next http.Handler, o ServerOptions) http.Handler { 167 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 168 | // Retrieve and remove URL signature from request parameters 169 | query := r.URL.Query() 170 | sign := query.Get("sign") 171 | query.Del("sign") 172 | 173 | // Compute expected URL signature 174 | h := hmac.New(sha256.New, []byte(o.URLSignatureKey)) 175 | _, _ = h.Write([]byte(r.URL.Path)) 176 | _, _ = h.Write([]byte(query.Encode())) 177 | expectedSign := h.Sum(nil) 178 | 179 | urlSign, err := base64.RawURLEncoding.DecodeString(sign) 180 | if err != nil { 181 | ErrorReply(r, w, ErrInvalidURLSignature, o) 182 | return 183 | } 184 | 185 | if !hmac.Equal(urlSign, expectedSign) { 186 | ErrorReply(r, w, ErrURLSignatureMismatch, o) 187 | return 188 | } 189 | 190 | next.ServeHTTP(w, r) 191 | }) 192 | } 193 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/h2non/bimg" 8 | ) 9 | 10 | // ImageOptions represent all the supported image transformation params as first level members 11 | type ImageOptions struct { 12 | IsDefinedField 13 | 14 | Width int 15 | Height int 16 | AreaWidth int 17 | AreaHeight int 18 | Quality int 19 | Compression int 20 | Rotate int 21 | Top int 22 | Left int 23 | Margin int 24 | Factor int 25 | DPI int 26 | TextWidth int 27 | Flip bool 28 | Flop bool 29 | Force bool 30 | Embed bool 31 | NoCrop bool 32 | NoReplicate bool 33 | NoRotation bool 34 | NoProfile bool 35 | StripMetadata bool 36 | Opacity float32 37 | Sigma float64 38 | MinAmpl float64 39 | Text string 40 | Image string 41 | Font string 42 | Type string 43 | AspectRatio string 44 | Color []uint8 45 | Background []uint8 46 | Interlace bool 47 | Speed int 48 | Extend bimg.Extend 49 | Gravity bimg.Gravity 50 | Colorspace bimg.Interpretation 51 | Operations PipelineOperations 52 | } 53 | 54 | // IsDefinedField holds boolean ImageOptions fields. If true it means the field was specified in the request. This 55 | // metadata allows for sane usage of default (false) values. 56 | type IsDefinedField struct { 57 | Flip bool 58 | Flop bool 59 | Force bool 60 | Embed bool 61 | NoCrop bool 62 | NoReplicate bool 63 | NoRotation bool 64 | NoProfile bool 65 | StripMetadata bool 66 | Interlace bool 67 | Palette bool 68 | } 69 | 70 | // PipelineOperation represents the structure for an operation field. 71 | type PipelineOperation struct { 72 | Name string `json:"operation"` 73 | IgnoreFailure bool `json:"ignore_failure"` 74 | Params map[string]interface{} `json:"params"` 75 | ImageOptions ImageOptions `json:"-"` 76 | Operation Operation `json:"-"` 77 | } 78 | 79 | // PipelineOperations defines the expected interface for a list of operations. 80 | type PipelineOperations []PipelineOperation 81 | 82 | func transformByAspectRatio(params map[string]interface{}) (width, height int) { 83 | width, _ = coerceTypeInt(params["width"]) 84 | height, _ = coerceTypeInt(params["height"]) 85 | 86 | aspectRatio, ok := params["aspectratio"].(map[string]int) 87 | if !ok { 88 | return 89 | } 90 | 91 | if width != 0 { 92 | height = width / aspectRatio["width"] * aspectRatio["height"] 93 | } else { 94 | width = height / aspectRatio["height"] * aspectRatio["width"] 95 | } 96 | 97 | return 98 | } 99 | 100 | func parseAspectRatio(val string) map[string]int { 101 | val = strings.TrimSpace(strings.ToLower(val)) 102 | slicedVal := strings.Split(val, ":") 103 | 104 | if len(slicedVal) < 2 { 105 | return nil 106 | } 107 | 108 | width, _ := strconv.Atoi(slicedVal[0]) 109 | height, _ := strconv.Atoi(slicedVal[1]) 110 | 111 | return map[string]int{ 112 | "width": width, 113 | "height": height, 114 | } 115 | } 116 | 117 | func shouldTransformByAspectRatio(height, width int) bool { 118 | 119 | // override aspect ratio parameters if width and height is given or not given at all 120 | if (width != 0 && height != 0) || (width == 0 && height == 0) { 121 | return false 122 | } 123 | 124 | return true 125 | } 126 | 127 | // BimgOptions creates a new bimg compatible options struct mapping the fields properly 128 | func BimgOptions(o ImageOptions) bimg.Options { 129 | opts := bimg.Options{ 130 | Width: o.Width, 131 | Height: o.Height, 132 | Flip: o.Flip, 133 | Flop: o.Flop, 134 | Quality: o.Quality, 135 | Compression: o.Compression, 136 | NoAutoRotate: o.NoRotation, 137 | NoProfile: o.NoProfile, 138 | Force: o.Force, 139 | Gravity: o.Gravity, 140 | Embed: o.Embed, 141 | Extend: o.Extend, 142 | Interpretation: o.Colorspace, 143 | StripMetadata: o.StripMetadata, 144 | Type: ImageType(o.Type), 145 | Rotate: bimg.Angle(o.Rotate), 146 | Interlace: o.Interlace, 147 | Palette: o.Palette, 148 | Speed: o.Speed, 149 | } 150 | 151 | if len(o.Background) != 0 { 152 | opts.Background = bimg.Color{R: o.Background[0], G: o.Background[1], B: o.Background[2]} 153 | } 154 | 155 | if shouldTransformByAspectRatio(opts.Height, opts.Width) && o.AspectRatio != "" { 156 | params := make(map[string]interface{}) 157 | params["height"] = opts.Height 158 | params["width"] = opts.Width 159 | params["aspectratio"] = parseAspectRatio(o.AspectRatio) 160 | 161 | opts.Width, opts.Height = transformByAspectRatio(params) 162 | } 163 | 164 | if o.Sigma > 0 || o.MinAmpl > 0 { 165 | opts.GaussianBlur = bimg.GaussianBlur{ 166 | Sigma: o.Sigma, 167 | MinAmpl: o.MinAmpl, 168 | } 169 | } 170 | 171 | return opts 172 | } 173 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestBimgOptions(t *testing.T) { 6 | imgOpts := ImageOptions{ 7 | Width: 500, 8 | Height: 600, 9 | } 10 | opts := BimgOptions(imgOpts) 11 | 12 | if opts.Width != imgOpts.Width || opts.Height != imgOpts.Height { 13 | t.Error("Invalid width and height") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /params.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "math" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/h2non/bimg" 13 | ) 14 | 15 | var ErrUnsupportedValue = errors.New("unsupported value") 16 | 17 | // Coercion is the type that type coerces a parameter and defines the appropriate field on ImageOptions 18 | type Coercion func(*ImageOptions, interface{}) error 19 | 20 | var paramTypeCoercions = map[string]Coercion{ 21 | "width": coerceWidth, 22 | "height": coerceHeight, 23 | "quality": coerceQuality, 24 | "top": coerceTop, 25 | "left": coerceLeft, 26 | "areawidth": coerceAreaWidth, 27 | "areaheight": coerceAreaHeight, 28 | "compression": coerceCompression, 29 | "rotate": coerceRotate, 30 | "margin": coerceMargin, 31 | "factor": coerceFactor, 32 | "dpi": coerceDPI, 33 | "textwidth": coerceTextWidth, 34 | "opacity": coerceOpacity, 35 | "flip": coerceFlip, 36 | "flop": coerceFlop, 37 | "nocrop": coerceNoCrop, 38 | "noprofile": coerceNoProfile, 39 | "norotation": coerceNoRotation, 40 | "noreplicate": coerceNoReplicate, 41 | "force": coerceForce, 42 | "embed": coerceEmbed, 43 | "stripmeta": coerceStripMeta, 44 | "text": coerceText, 45 | "image": coerceImage, 46 | "font": coerceFont, 47 | "type": coerceImageType, 48 | "color": coerceColor, 49 | "colorspace": coerceColorSpace, 50 | "gravity": coerceGravity, 51 | "background": coerceBackground, 52 | "extend": coerceExtend, 53 | "sigma": coerceSigma, 54 | "minampl": coerceMinAmpl, 55 | "operations": coerceOperations, 56 | "interlace": coerceInterlace, 57 | "aspectratio": coerceAspectRatio, 58 | "palette": coercePalette, 59 | "speed": coerceSpeed, 60 | } 61 | 62 | func coerceTypeInt(param interface{}) (int, error) { 63 | if v, ok := param.(int); ok { 64 | return v, nil 65 | } 66 | 67 | if v, ok := param.(float64); ok { 68 | return int(v), nil 69 | } 70 | 71 | if v, ok := param.(string); ok { 72 | return parseInt(v) 73 | } 74 | 75 | return 0, ErrUnsupportedValue 76 | } 77 | 78 | func coerceTypeFloat(param interface{}) (float64, error) { 79 | if v, ok := param.(float64); ok { 80 | return v, nil 81 | } 82 | 83 | if v, ok := param.(int); ok { 84 | return float64(v), nil 85 | } 86 | 87 | if v, ok := param.(string); ok { 88 | result, err := parseFloat(v) 89 | if err != nil { 90 | return 0, ErrUnsupportedValue 91 | } 92 | 93 | return result, nil 94 | } 95 | 96 | return 0, ErrUnsupportedValue 97 | } 98 | 99 | func coerceTypeBool(param interface{}) (bool, error) { 100 | if v, ok := param.(bool); ok { 101 | return v, nil 102 | } 103 | 104 | if v, ok := param.(string); ok { 105 | result, err := parseBool(v) 106 | if err != nil { 107 | return false, ErrUnsupportedValue 108 | } 109 | 110 | return result, nil 111 | } 112 | 113 | return false, ErrUnsupportedValue 114 | } 115 | 116 | func coerceTypeString(param interface{}) (string, error) { 117 | if v, ok := param.(string); ok { 118 | return v, nil 119 | } 120 | 121 | return "", ErrUnsupportedValue 122 | } 123 | 124 | func coerceHeight(io *ImageOptions, param interface{}) (err error) { 125 | io.Height, err = coerceTypeInt(param) 126 | return err 127 | } 128 | 129 | func coerceWidth(io *ImageOptions, param interface{}) (err error) { 130 | io.Width, err = coerceTypeInt(param) 131 | return err 132 | } 133 | 134 | func coerceQuality(io *ImageOptions, param interface{}) (err error) { 135 | io.Quality, err = coerceTypeInt(param) 136 | return err 137 | } 138 | 139 | func coerceTop(io *ImageOptions, param interface{}) (err error) { 140 | io.Top, err = coerceTypeInt(param) 141 | return err 142 | } 143 | 144 | func coerceLeft(io *ImageOptions, param interface{}) (err error) { 145 | io.Left, err = coerceTypeInt(param) 146 | return err 147 | } 148 | 149 | func coerceAreaWidth(io *ImageOptions, param interface{}) (err error) { 150 | io.AreaWidth, err = coerceTypeInt(param) 151 | return err 152 | } 153 | 154 | func coerceAreaHeight(io *ImageOptions, param interface{}) (err error) { 155 | io.AreaHeight, err = coerceTypeInt(param) 156 | return err 157 | } 158 | 159 | func coerceCompression(io *ImageOptions, param interface{}) (err error) { 160 | io.Compression, err = coerceTypeInt(param) 161 | return err 162 | } 163 | 164 | func coerceRotate(io *ImageOptions, param interface{}) (err error) { 165 | io.Rotate, err = coerceTypeInt(param) 166 | return err 167 | } 168 | 169 | func coerceMargin(io *ImageOptions, param interface{}) (err error) { 170 | io.Margin, err = coerceTypeInt(param) 171 | return err 172 | } 173 | 174 | func coerceFactor(io *ImageOptions, param interface{}) (err error) { 175 | io.Factor, err = coerceTypeInt(param) 176 | return err 177 | } 178 | 179 | func coerceDPI(io *ImageOptions, param interface{}) (err error) { 180 | io.DPI, err = coerceTypeInt(param) 181 | return err 182 | } 183 | 184 | func coerceTextWidth(io *ImageOptions, param interface{}) (err error) { 185 | io.TextWidth, err = coerceTypeInt(param) 186 | return err 187 | } 188 | 189 | func coerceOpacity(io *ImageOptions, param interface{}) (err error) { 190 | v, err := coerceTypeFloat(param) 191 | io.Opacity = float32(v) 192 | return err 193 | } 194 | 195 | func coerceFlip(io *ImageOptions, param interface{}) (err error) { 196 | io.Flip, err = coerceTypeBool(param) 197 | io.IsDefinedField.Flip = true 198 | return err 199 | } 200 | 201 | func coerceFlop(io *ImageOptions, param interface{}) (err error) { 202 | io.Flop, err = coerceTypeBool(param) 203 | io.IsDefinedField.Flop = true 204 | return err 205 | } 206 | 207 | func coerceNoCrop(io *ImageOptions, param interface{}) (err error) { 208 | io.NoCrop, err = coerceTypeBool(param) 209 | io.IsDefinedField.NoCrop = true 210 | return err 211 | } 212 | 213 | func coerceNoProfile(io *ImageOptions, param interface{}) (err error) { 214 | io.NoProfile, err = coerceTypeBool(param) 215 | io.IsDefinedField.NoProfile = true 216 | return err 217 | } 218 | 219 | func coerceNoRotation(io *ImageOptions, param interface{}) (err error) { 220 | io.NoRotation, err = coerceTypeBool(param) 221 | io.IsDefinedField.NoRotation = true 222 | return err 223 | } 224 | 225 | func coerceNoReplicate(io *ImageOptions, param interface{}) (err error) { 226 | io.NoReplicate, err = coerceTypeBool(param) 227 | io.IsDefinedField.NoReplicate = true 228 | return err 229 | } 230 | 231 | func coerceForce(io *ImageOptions, param interface{}) (err error) { 232 | io.Force, err = coerceTypeBool(param) 233 | io.IsDefinedField.Force = true 234 | return err 235 | } 236 | 237 | func coerceEmbed(io *ImageOptions, param interface{}) (err error) { 238 | io.Embed, err = coerceTypeBool(param) 239 | io.IsDefinedField.Embed = true 240 | return err 241 | } 242 | 243 | func coerceStripMeta(io *ImageOptions, param interface{}) (err error) { 244 | io.StripMetadata, err = coerceTypeBool(param) 245 | io.IsDefinedField.StripMetadata = true 246 | return err 247 | } 248 | 249 | func coerceText(io *ImageOptions, param interface{}) (err error) { 250 | io.Text, err = coerceTypeString(param) 251 | return err 252 | } 253 | 254 | func coerceImage(io *ImageOptions, param interface{}) (err error) { 255 | io.Image, err = coerceTypeString(param) 256 | return err 257 | } 258 | 259 | func coerceFont(io *ImageOptions, param interface{}) (err error) { 260 | io.Font, err = coerceTypeString(param) 261 | return err 262 | } 263 | 264 | func coerceImageType(io *ImageOptions, param interface{}) (err error) { 265 | io.Type, err = coerceTypeString(param) 266 | return err 267 | } 268 | 269 | func coerceColor(io *ImageOptions, param interface{}) error { 270 | if v, ok := param.(string); ok { 271 | io.Color = parseColor(v) 272 | return nil 273 | } 274 | 275 | return ErrUnsupportedValue 276 | } 277 | 278 | func coerceColorSpace(io *ImageOptions, param interface{}) error { 279 | if v, ok := param.(string); ok { 280 | io.Colorspace = parseColorspace(v) 281 | return nil 282 | } 283 | 284 | return ErrUnsupportedValue 285 | } 286 | 287 | func coerceGravity(io *ImageOptions, param interface{}) error { 288 | if v, ok := param.(string); ok { 289 | io.Gravity = parseGravity(v) 290 | return nil 291 | } 292 | 293 | return ErrUnsupportedValue 294 | } 295 | 296 | func coerceBackground(io *ImageOptions, param interface{}) error { 297 | if v, ok := param.(string); ok { 298 | io.Background = parseColor(v) 299 | return nil 300 | } 301 | 302 | return ErrUnsupportedValue 303 | } 304 | 305 | func coerceAspectRatio(io *ImageOptions, param interface{}) (err error) { 306 | io.AspectRatio, err = coerceTypeString(param) 307 | return err 308 | } 309 | 310 | func coerceExtend(io *ImageOptions, param interface{}) error { 311 | if v, ok := param.(string); ok { 312 | io.Extend = parseExtendMode(v) 313 | return nil 314 | } 315 | 316 | return ErrUnsupportedValue 317 | } 318 | 319 | func coerceSigma(io *ImageOptions, param interface{}) (err error) { 320 | io.Sigma, err = coerceTypeFloat(param) 321 | return err 322 | } 323 | 324 | func coerceMinAmpl(io *ImageOptions, param interface{}) (err error) { 325 | io.MinAmpl, err = coerceTypeFloat(param) 326 | return err 327 | } 328 | 329 | func coerceOperations(io *ImageOptions, param interface{}) (err error) { 330 | if v, ok := param.(string); ok { 331 | ops, err := parseJSONOperations(v) 332 | if err == nil { 333 | io.Operations = ops 334 | } 335 | 336 | return err 337 | } 338 | 339 | return ErrUnsupportedValue 340 | } 341 | 342 | func coerceInterlace(io *ImageOptions, param interface{}) (err error) { 343 | io.Interlace, err = coerceTypeBool(param) 344 | io.IsDefinedField.Interlace = true 345 | return err 346 | } 347 | 348 | func coercePalette(io *ImageOptions, param interface{}) (err error) { 349 | io.Palette, err = coerceTypeBool(param) 350 | io.IsDefinedField.Palette = true 351 | return err 352 | } 353 | 354 | func coerceSpeed(io *ImageOptions, param interface{}) (err error) { 355 | io.Speed, err = coerceTypeInt(param) 356 | return err 357 | } 358 | 359 | func buildParamsFromOperation(op PipelineOperation) (ImageOptions, error) { 360 | var options ImageOptions 361 | 362 | // Apply defaults 363 | options.Extend = bimg.ExtendCopy 364 | 365 | for key, value := range op.Params { 366 | fn, ok := paramTypeCoercions[key] 367 | if !ok { 368 | continue 369 | } 370 | 371 | err := fn(&options, value) 372 | if err != nil { 373 | return ImageOptions{}, fmt.Errorf(`error while processing parameter "%s" with value %q, error: %s`, key, value, err) 374 | } 375 | } 376 | 377 | return options, nil 378 | } 379 | 380 | // buildParamsFromQuery builds the ImageOptions type from untyped parameters 381 | func buildParamsFromQuery(query url.Values) (ImageOptions, error) { 382 | var options ImageOptions 383 | 384 | // Apply defaults 385 | options.Extend = bimg.ExtendCopy 386 | 387 | // Extract only known parameters 388 | for key := range query { 389 | fn, ok := paramTypeCoercions[key] 390 | if !ok { 391 | continue 392 | } 393 | 394 | value := query.Get(key) 395 | err := fn(&options, value) 396 | if err != nil { 397 | return ImageOptions{}, fmt.Errorf(`error while processing parameter "%s" with value %q, error: %s`, key, value, err) 398 | } 399 | } 400 | 401 | return options, nil 402 | } 403 | 404 | func parseBool(val string) (bool, error) { 405 | if val == "" { 406 | return false, nil 407 | } 408 | 409 | return strconv.ParseBool(val) 410 | } 411 | 412 | func parseInt(param string) (int, error) { 413 | if param == "" { 414 | return 0, nil 415 | } 416 | 417 | f, err := parseFloat(param) 418 | return int(math.Floor(f + 0.5)), err 419 | } 420 | 421 | func parseFloat(param string) (float64, error) { 422 | if param == "" { 423 | return 0.0, nil 424 | } 425 | 426 | val, err := strconv.ParseFloat(param, 64) 427 | return math.Abs(val), err 428 | } 429 | 430 | func parseColorspace(val string) bimg.Interpretation { 431 | if val == "bw" { 432 | return bimg.InterpretationBW 433 | } 434 | return bimg.InterpretationSRGB 435 | } 436 | 437 | func parseColor(val string) []uint8 { 438 | const max float64 = 255 439 | var buf []uint8 440 | if val != "" { 441 | for _, num := range strings.Split(val, ",") { 442 | n, _ := strconv.ParseUint(strings.Trim(num, " "), 10, 8) 443 | buf = append(buf, uint8(math.Min(float64(n), max))) 444 | } 445 | } 446 | return buf 447 | } 448 | 449 | func parseJSONOperations(data string) (PipelineOperations, error) { 450 | var operations PipelineOperations 451 | 452 | // Fewer than 2 characters cannot be valid JSON. We assume empty operation. 453 | if len(data) < 2 { 454 | return operations, nil 455 | } 456 | 457 | d := json.NewDecoder(strings.NewReader(data)) 458 | d.DisallowUnknownFields() 459 | 460 | err := d.Decode(&operations) 461 | return operations, err 462 | } 463 | 464 | func parseExtendMode(val string) bimg.Extend { 465 | val = strings.TrimSpace(strings.ToLower(val)) 466 | if val == "white" { 467 | return bimg.ExtendWhite 468 | } 469 | if val == "black" { 470 | return bimg.ExtendBlack 471 | } 472 | if val == "copy" { 473 | return bimg.ExtendCopy 474 | } 475 | if val == "background" { 476 | return bimg.ExtendBackground 477 | } 478 | if val == "lastpixel" { 479 | return bimg.ExtendLast 480 | } 481 | return bimg.ExtendMirror 482 | } 483 | 484 | func parseGravity(val string) bimg.Gravity { 485 | var m = map[string]bimg.Gravity{ 486 | "south": bimg.GravitySouth, 487 | "north": bimg.GravityNorth, 488 | "east": bimg.GravityEast, 489 | "west": bimg.GravityWest, 490 | "smart": bimg.GravitySmart, 491 | } 492 | 493 | val = strings.TrimSpace(strings.ToLower(val)) 494 | if g, ok := m[val]; ok { 495 | return g 496 | } 497 | 498 | return bimg.GravityCentre 499 | } 500 | -------------------------------------------------------------------------------- /params_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/h2non/bimg" 9 | ) 10 | 11 | const epsilon = 0.0001 12 | 13 | func TestReadParams(t *testing.T) { 14 | q := url.Values{} 15 | q.Set("width", "100") 16 | q.Add("height", "80") 17 | q.Add("noreplicate", "1") 18 | q.Add("opacity", "0.2") 19 | q.Add("text", "hello") 20 | q.Add("background", "255,10,20") 21 | q.Add("interlace", "true") 22 | 23 | params, err := buildParamsFromQuery(q) 24 | if err != nil { 25 | t.Errorf("Failed reading params, %s", err) 26 | } 27 | 28 | assert := params.Width == 100 && 29 | params.Height == 80 && 30 | params.NoReplicate == true && 31 | params.Opacity == 0.2 && 32 | params.Text == "hello" && 33 | params.Background[0] == 255 && 34 | params.Background[1] == 10 && 35 | params.Background[2] == 20 && 36 | params.Interlace == true 37 | 38 | if assert == false { 39 | t.Error("Invalid params") 40 | } 41 | } 42 | 43 | func TestParseParam(t *testing.T) { 44 | intCases := []struct { 45 | value string 46 | expected int 47 | }{ 48 | {"1", 1}, 49 | {"0100", 100}, 50 | {"-100", 100}, 51 | {"99.02", 99}, 52 | {"99.9", 100}, 53 | } 54 | 55 | for _, test := range intCases { 56 | val, _ := parseInt(test.value) 57 | if val != test.expected { 58 | t.Errorf("Invalid param: %s != %d", test.value, test.expected) 59 | } 60 | } 61 | 62 | floatCases := []struct { 63 | value string 64 | expected float64 65 | }{ 66 | {"1.1", 1.1}, 67 | {"01.1", 1.1}, 68 | {"-1.10", 1.10}, 69 | {"99.999999", 99.999999}, 70 | } 71 | 72 | for _, test := range floatCases { 73 | val, _ := parseFloat(test.value) 74 | if val != test.expected { 75 | t.Errorf("Invalid param: %#v != %#v", val, test.expected) 76 | } 77 | } 78 | 79 | boolCases := []struct { 80 | value string 81 | expected bool 82 | }{ 83 | {"true", true}, 84 | {"false", false}, 85 | {"1", true}, 86 | {"1.1", false}, 87 | {"-1", false}, 88 | {"0", false}, 89 | {"0.0", false}, 90 | {"no", false}, 91 | {"yes", false}, 92 | } 93 | 94 | for _, test := range boolCases { 95 | val, _ := parseBool(test.value) 96 | if val != test.expected { 97 | t.Errorf("Invalid param: %#v != %#v", val, test.expected) 98 | } 99 | } 100 | } 101 | 102 | func TestParseColor(t *testing.T) { 103 | cases := []struct { 104 | value string 105 | expected []uint8 106 | }{ 107 | {"200,100,20", []uint8{200, 100, 20}}, 108 | {"0,280,200", []uint8{0, 255, 200}}, 109 | {" -1, 256 , 50", []uint8{0, 255, 50}}, 110 | {" a, 20 , &hel0", []uint8{0, 20, 0}}, 111 | {"", []uint8{}}, 112 | } 113 | 114 | for _, color := range cases { 115 | c := parseColor(color.value) 116 | l := len(color.expected) 117 | 118 | if len(c) != l { 119 | t.Errorf("Invalid color length: %#v", c) 120 | } 121 | if l == 0 { 122 | continue 123 | } 124 | 125 | assert := c[0] == color.expected[0] && 126 | c[1] == color.expected[1] && 127 | c[2] == color.expected[2] 128 | 129 | if assert == false { 130 | t.Errorf("Invalid color schema: %#v <> %#v", color.expected, c) 131 | } 132 | } 133 | } 134 | 135 | func TestParseExtend(t *testing.T) { 136 | cases := []struct { 137 | value string 138 | expected bimg.Extend 139 | }{ 140 | {"white", bimg.ExtendWhite}, 141 | {"black", bimg.ExtendBlack}, 142 | {"copy", bimg.ExtendCopy}, 143 | {"mirror", bimg.ExtendMirror}, 144 | {"lastpixel", bimg.ExtendLast}, 145 | {"background", bimg.ExtendBackground}, 146 | {" BACKGROUND ", bimg.ExtendBackground}, 147 | {"invalid", bimg.ExtendMirror}, 148 | {"", bimg.ExtendMirror}, 149 | } 150 | 151 | for _, extend := range cases { 152 | c := parseExtendMode(extend.value) 153 | if c != extend.expected { 154 | t.Errorf("Invalid extend value : %d != %d", c, extend.expected) 155 | } 156 | } 157 | } 158 | 159 | func TestGravity(t *testing.T) { 160 | cases := []struct { 161 | gravityValue string 162 | smartCropValue bool 163 | }{ 164 | {gravityValue: "foo", smartCropValue: false}, 165 | {gravityValue: "smart", smartCropValue: true}, 166 | } 167 | 168 | for _, td := range cases { 169 | io, _ := buildParamsFromQuery(url.Values{"gravity": []string{td.gravityValue}}) 170 | if (io.Gravity == bimg.GravitySmart) != td.smartCropValue { 171 | t.Errorf("Expected %t to be %t, test data: %+v", io.Gravity == bimg.GravitySmart, td.smartCropValue, td) 172 | } 173 | } 174 | } 175 | 176 | func TestReadMapParams(t *testing.T) { 177 | cases := []struct { 178 | params map[string]interface{} 179 | expected ImageOptions 180 | }{ 181 | { 182 | map[string]interface{}{ 183 | "width": 100, 184 | "opacity": 0.1, 185 | "type": "webp", 186 | "embed": true, 187 | "gravity": "west", 188 | "color": "255,200,150", 189 | }, 190 | ImageOptions{ 191 | Width: 100, 192 | Opacity: 0.1, 193 | Type: "webp", 194 | Embed: true, 195 | Gravity: bimg.GravityWest, 196 | Color: []uint8{255, 200, 150}, 197 | }, 198 | }, 199 | } 200 | 201 | for _, test := range cases { 202 | opts, err := buildParamsFromOperation(PipelineOperation{Params: test.params}) 203 | if err != nil { 204 | t.Errorf("Error reading parameters %s", err) 205 | t.FailNow() 206 | } 207 | if opts.Width != test.expected.Width { 208 | t.Errorf("Invalid width: %d != %d", opts.Width, test.expected.Width) 209 | } 210 | if opts.Opacity != test.expected.Opacity { 211 | t.Errorf("Invalid opacity: %#v != %#v", opts.Opacity, test.expected.Opacity) 212 | } 213 | if opts.Type != test.expected.Type { 214 | t.Errorf("Invalid type: %s != %s", opts.Type, test.expected.Type) 215 | } 216 | if opts.Embed != test.expected.Embed { 217 | t.Errorf("Invalid embed: %#v != %#v", opts.Embed, test.expected.Embed) 218 | } 219 | if opts.Gravity != test.expected.Gravity { 220 | t.Errorf("Invalid gravity: %#v != %#v", opts.Gravity, test.expected.Gravity) 221 | } 222 | if opts.Color[0] != test.expected.Color[0] || opts.Color[1] != test.expected.Color[1] || opts.Color[2] != test.expected.Color[2] { 223 | t.Errorf("Invalid color: %#v != %#v", opts.Color, test.expected.Color) 224 | } 225 | } 226 | } 227 | 228 | func TestParseFunctions(t *testing.T) { 229 | t.Run("parseBool", func(t *testing.T) { 230 | if r, err := parseBool("true"); r != true { 231 | t.Errorf("Expected string true to result a native type true %s", err) 232 | } 233 | 234 | if r, err := parseBool("false"); r != false { 235 | t.Errorf("Expected string false to result a native type false %s", err) 236 | } 237 | 238 | // A special case that we support 239 | if _, err := parseBool(""); err != nil { 240 | t.Errorf("Expected blank values to default to false, it didn't! %s", err) 241 | } 242 | 243 | if r, err := parseBool("foo"); err == nil { 244 | t.Errorf("Expected malformed values to result in an error, it didn't! %+v", r) 245 | } 246 | }) 247 | } 248 | 249 | func TestBuildParamsFromOperation(t *testing.T) { 250 | op := PipelineOperation{ 251 | Params: map[string]interface{}{ 252 | "width": 200, 253 | "opacity": 2.2, 254 | "force": true, 255 | "stripmeta": false, 256 | "type": "jpeg", 257 | "background": "255,12,3", 258 | }, 259 | } 260 | 261 | options, err := buildParamsFromOperation(op) 262 | if err != nil { 263 | t.Errorf("Expected this to work! %s", err) 264 | } 265 | 266 | if input := op.Params["width"].(int); options.Width != 200 { 267 | t.Errorf("Expected the Width to be coerced with the correct value of %d", input) 268 | } 269 | 270 | if input := op.Params["opacity"].(float64); math.Abs(input-float64(options.Opacity)) > epsilon { 271 | t.Errorf("Expected the Opacity to be coerced with the correct value of %f", input) 272 | } 273 | 274 | if options.Force != true || options.StripMetadata != false { 275 | t.Errorf("Expected boolean parameters to result in their respective value's\n%+v", options) 276 | } 277 | 278 | if input := op.Params["background"].(string); options.Background[0] != 255 { 279 | t.Errorf("Expected color parameter to be coerced with the correct value of %s", input) 280 | } 281 | } 282 | 283 | func TestCoerceTypeFns(t *testing.T) { 284 | t.Run("coerceTypeInt", func(t *testing.T) { 285 | cases := []struct { 286 | Input interface{} 287 | Expect int 288 | Err error 289 | }{ 290 | {Input: "200", Expect: 200}, 291 | {Input: int(200), Expect: 200}, 292 | {Input: float64(200), Expect: 200}, 293 | {Input: false, Expect: 0, Err: ErrUnsupportedValue}, 294 | } 295 | 296 | for _, tc := range cases { 297 | 298 | result, err := coerceTypeInt(tc.Input) 299 | if err != nil && tc.Err == nil { 300 | t.Errorf("Did not expect error %s\n%+v", err, tc) 301 | t.FailNow() 302 | } 303 | 304 | if tc.Err != nil && tc.Err != err { 305 | t.Errorf("Expected an error to be thrown\nExpected: %s\nReceived: %s", tc.Err, err) 306 | t.FailNow() 307 | } 308 | 309 | if tc.Err == nil && result != tc.Expect { 310 | t.Errorf("Expected proper coercion %s\n%+v\n%+v", err, result, tc) 311 | } 312 | } 313 | }) 314 | 315 | t.Run("coerceTypeFloat", func(t *testing.T) { 316 | cases := []struct { 317 | Input interface{} 318 | Expect float64 319 | Err error 320 | }{ 321 | {Input: "200", Expect: 200}, 322 | {Input: int(200), Expect: 200}, 323 | {Input: float64(200), Expect: 200}, 324 | {Input: false, Expect: 0, Err: ErrUnsupportedValue}, 325 | } 326 | 327 | for _, tc := range cases { 328 | 329 | result, err := coerceTypeFloat(tc.Input) 330 | if err != nil && tc.Err == nil { 331 | t.Errorf("Did not expect error %s\n%+v", err, tc) 332 | t.FailNow() 333 | } 334 | 335 | if tc.Err != nil && tc.Err != err { 336 | t.Errorf("Expected an error to be thrown\nExpected: %s\nReceived: %s", tc.Err, err) 337 | t.FailNow() 338 | } 339 | 340 | if tc.Err == nil && math.Abs(result-tc.Expect) > epsilon { 341 | t.Errorf("Expected proper coercion %s\n%+v\n%+v", err, result, tc) 342 | } 343 | } 344 | }) 345 | 346 | t.Run("coerceTypeBool", func(t *testing.T) { 347 | cases := []struct { 348 | Input interface{} 349 | Expect bool 350 | Err error 351 | }{ 352 | {Input: "true", Expect: true}, 353 | {Input: true, Expect: true}, 354 | {Input: "1", Expect: true}, 355 | {Input: "bubblegum", Expect: false, Err: ErrUnsupportedValue}, 356 | } 357 | 358 | for _, tc := range cases { 359 | 360 | result, err := coerceTypeBool(tc.Input) 361 | if err != nil && tc.Err == nil { 362 | t.Errorf("Did not expect error %s\n%+v", err, tc) 363 | t.FailNow() 364 | } 365 | 366 | if tc.Err != nil && tc.Err != err { 367 | t.Errorf("Expected an error to be thrown\nExpected: %s\nReceived: %s", tc.Err, err) 368 | t.FailNow() 369 | } 370 | 371 | if tc.Err == nil && result != tc.Expect { 372 | t.Errorf("Expected proper coercion %s\n%+v\n%+v", err, result, tc) 373 | } 374 | } 375 | }) 376 | 377 | t.Run("coerceTypeString", func(t *testing.T) { 378 | cases := []struct { 379 | Input interface{} 380 | Expect string 381 | Err error 382 | }{ 383 | {Input: "true", Expect: "true"}, 384 | {Input: false, Err: ErrUnsupportedValue}, 385 | {Input: 0.0, Err: ErrUnsupportedValue}, 386 | {Input: 0, Err: ErrUnsupportedValue}, 387 | } 388 | 389 | for _, tc := range cases { 390 | 391 | result, err := coerceTypeString(tc.Input) 392 | if err != nil && tc.Err == nil { 393 | t.Errorf("Did not expect error %s\n%+v", err, tc) 394 | t.FailNow() 395 | } 396 | 397 | if tc.Err != nil && tc.Err != err { 398 | t.Errorf("Expected an error to be thrown\nExpected: %s\nReceived: %s", tc.Err, err) 399 | t.FailNow() 400 | } 401 | 402 | if tc.Err == nil && result != tc.Expect { 403 | t.Errorf("Expected proper coercion %s\n%+v\n%+v", err, result, tc) 404 | } 405 | } 406 | }) 407 | } 408 | -------------------------------------------------------------------------------- /placeholder.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "io/ioutil" 6 | "strings" 7 | ) 8 | 9 | const placeholderData = `/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAGQAZADASIAAhEBAxEB/8QAGwABAAMBAQEBAAAAAAAAAAAAAAQFBgMCAQf/xAA5EAEAAQQBAQUFBAcJAAAAAAAAAQIDBBEFEgYUITFRE0FzobEVNWHBFjZTcZGS0SIjNIGCg8Lh8f/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwD9lAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeL92mzZru176aKZqnXpD2i8r925XwqvoCD+kWF6Xf5f+0vj+TsZ9VdNjr3RG56o0oezGJYyu894tU3Onp1v3b20eLhY+LNU49qmiavCde8EgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABF5X7tyvhVfRKReV+7cr4VX0BmuzvIWMHvHeJqjr6dajflv+rTYOdZzqKqseapimdTuNM52ZwsfM7z3m3FfR09PjMa3v0/c0uJiWMSmqnHtxRFU7mNzP1BRc5yOTicpTRbuVRaiKappjXj6pvD18jeybl7Npqos1U/2KfCIjxj3ef8VXz0RVz9mJ8p6In+LVgy+dyuXj8xdoorqrt01apt68/Dw+a14WM6YvVch1RNUx0RMx+PujyU8xFXazU/tN/JqwZ7meTyas6MLAmYqiYiZjzmfT8HHH5HOwM+ixyNU1UVa3vU6iffEuPF/wB52mrqq8+u5P1de18R3jHq980zHzBf8ncrs8fkXLdXTXTRMxPozeJyPKZdqqzjzVcub3Neo8I9PSF9yNU1cJeqnzmzv5IHZCI7rfn3zXEfIF1jRXGPai7v2kUR1bnfjrxdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAReV+7cr4VX0SnjItU37Fy1XMxTXTNMzHn4gzHZbKsY3evb3aLfV066p1vzaTHyrGRMxYu0XJjz6Z3pVfo3h/tMj+aP6JnG8XZ4+uuqzVcqmuNT1zE/kCk5z9YLH+j6tUgZfFWMrMoybld2LlOtRTMa8P8AJPBlY/W3/c/4tUgfZVj7R7713fa76tbjXlr0TwZGmqMDtNVVenpo9pVO/wAKonX1fe0V+jNz7FrGqi5qOndM7iZmf/F/yXGY+fqbsVU10+EV0+evRy4/hsbCu+1p6rlyPKavd+4HXlaYo4jIpjyi3pA7I/4O/wDE/KFzk2acjHuWa5mKa41Mx5uHHYFrj7VVFmquqKp6p65ifyBLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB//2Q==` 10 | 11 | var placeholder, _ = ioutil.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(placeholderData))) 12 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "os/signal" 10 | "path" 11 | "strconv" 12 | "strings" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | type ServerOptions struct { 18 | Port int 19 | Burst int 20 | Concurrency int 21 | HTTPCacheTTL int 22 | HTTPReadTimeout int 23 | HTTPWriteTimeout int 24 | MaxAllowedSize int 25 | MaxAllowedPixels float64 26 | CORS bool 27 | Gzip bool // deprecated 28 | AuthForwarding bool 29 | EnableURLSource bool 30 | EnablePlaceholder bool 31 | EnableURLSignature bool 32 | URLSignatureKey string 33 | Address string 34 | PathPrefix string 35 | APIKey string 36 | Mount string 37 | CertFile string 38 | KeyFile string 39 | Authorization string 40 | Placeholder string 41 | PlaceholderStatus int 42 | ForwardHeaders []string 43 | PlaceholderImage []byte 44 | Endpoints Endpoints 45 | AllowedOrigins []*url.URL 46 | LogLevel string 47 | ReturnSize bool 48 | } 49 | 50 | // Endpoints represents a list of endpoint names to disable. 51 | type Endpoints []string 52 | 53 | // IsValid validates if a given HTTP request endpoint is valid or not. 54 | func (e Endpoints) IsValid(r *http.Request) bool { 55 | parts := strings.Split(r.URL.Path, "/") 56 | endpoint := parts[len(parts)-1] 57 | for _, name := range e { 58 | if endpoint == name { 59 | return false 60 | } 61 | } 62 | return true 63 | } 64 | 65 | func Server(o ServerOptions) { 66 | addr := o.Address + ":" + strconv.Itoa(o.Port) 67 | handler := NewLog(NewServerMux(o), os.Stdout, o.LogLevel) 68 | 69 | server := &http.Server{ 70 | Addr: addr, 71 | Handler: handler, 72 | MaxHeaderBytes: 1 << 20, 73 | ReadTimeout: time.Duration(o.HTTPReadTimeout) * time.Second, 74 | WriteTimeout: time.Duration(o.HTTPWriteTimeout) * time.Second, 75 | } 76 | 77 | done := make(chan os.Signal, 1) 78 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 79 | 80 | go func() { 81 | if err := listenAndServe(server, o); err != nil && err != http.ErrServerClosed { 82 | log.Fatalf("listen: %s\n", err) 83 | } 84 | }() 85 | 86 | <-done 87 | log.Print("Graceful shutdown") 88 | 89 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 90 | defer func() { 91 | // extra handling here 92 | cancel() 93 | }() 94 | 95 | if err := server.Shutdown(ctx); err != nil { 96 | log.Fatalf("Server Shutdown Failed:%+v", err) 97 | } 98 | } 99 | 100 | func listenAndServe(s *http.Server, o ServerOptions) error { 101 | if o.CertFile != "" && o.KeyFile != "" { 102 | return s.ListenAndServeTLS(o.CertFile, o.KeyFile) 103 | } 104 | return s.ListenAndServe() 105 | } 106 | 107 | func join(o ServerOptions, route string) string { 108 | return path.Join(o.PathPrefix, route) 109 | } 110 | 111 | // NewServerMux creates a new HTTP server route multiplexer. 112 | func NewServerMux(o ServerOptions) http.Handler { 113 | mux := http.NewServeMux() 114 | 115 | mux.Handle(join(o, "/"), Middleware(indexController(o), o)) 116 | mux.Handle(join(o, "/form"), Middleware(formController(o), o)) 117 | mux.Handle(join(o, "/health"), Middleware(healthController, o)) 118 | 119 | image := ImageMiddleware(o) 120 | mux.Handle(join(o, "/resize"), image(Resize)) 121 | mux.Handle(join(o, "/fit"), image(Fit)) 122 | mux.Handle(join(o, "/enlarge"), image(Enlarge)) 123 | mux.Handle(join(o, "/extract"), image(Extract)) 124 | mux.Handle(join(o, "/crop"), image(Crop)) 125 | mux.Handle(join(o, "/smartcrop"), image(SmartCrop)) 126 | mux.Handle(join(o, "/rotate"), image(Rotate)) 127 | mux.Handle(join(o, "/autorotate"), image(AutoRotate)) 128 | mux.Handle(join(o, "/flip"), image(Flip)) 129 | mux.Handle(join(o, "/flop"), image(Flop)) 130 | mux.Handle(join(o, "/thumbnail"), image(Thumbnail)) 131 | mux.Handle(join(o, "/zoom"), image(Zoom)) 132 | mux.Handle(join(o, "/convert"), image(Convert)) 133 | mux.Handle(join(o, "/watermark"), image(Watermark)) 134 | mux.Handle(join(o, "/watermarkimage"), image(WatermarkImage)) 135 | mux.Handle(join(o, "/info"), image(Info)) 136 | mux.Handle(join(o, "/blur"), image(GaussianBlur)) 137 | mux.Handle(join(o, "/pipeline"), image(Pipeline)) 138 | 139 | return mux 140 | } 141 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "path" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/h2non/bimg" 16 | ) 17 | 18 | func TestIndex(t *testing.T) { 19 | opts := ServerOptions{PathPrefix: "/", MaxAllowedPixels: 18.0} 20 | ts := testServer(indexController(opts)) 21 | defer ts.Close() 22 | 23 | res, err := http.Get(ts.URL) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | if res.StatusCode != 200 { 29 | t.Fatalf("Invalid response status: %s", res.Status) 30 | } 31 | 32 | body, err := ioutil.ReadAll(res.Body) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | if strings.Contains(string(body), "imaginary") == false { 38 | t.Fatalf("Invalid body response: %s", body) 39 | } 40 | } 41 | 42 | func TestCrop(t *testing.T) { 43 | ts := testServer(controller(Crop)) 44 | buf := readFile("large.jpg") 45 | url := ts.URL + "?width=300" 46 | defer ts.Close() 47 | 48 | res, err := http.Post(url, "image/jpeg", buf) 49 | if err != nil { 50 | t.Fatal("Cannot perform the request") 51 | } 52 | 53 | if res.StatusCode != 200 { 54 | t.Fatalf("Invalid response status: %s", res.Status) 55 | } 56 | 57 | if res.Header.Get("Content-Length") == "" { 58 | t.Fatal("Empty content length response") 59 | } 60 | 61 | image, err := ioutil.ReadAll(res.Body) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | if len(image) == 0 { 66 | t.Fatalf("Empty response body") 67 | } 68 | 69 | err = assertSize(image, 300, 1080) 70 | if err != nil { 71 | t.Error(err) 72 | } 73 | 74 | if bimg.DetermineImageTypeName(image) != "jpeg" { 75 | t.Fatalf("Invalid image type") 76 | } 77 | } 78 | 79 | func TestResize(t *testing.T) { 80 | ts := testServer(controller(Resize)) 81 | buf := readFile("large.jpg") 82 | url := ts.URL + "?width=300&nocrop=false" 83 | defer ts.Close() 84 | 85 | res, err := http.Post(url, "image/jpeg", buf) 86 | if err != nil { 87 | t.Fatal("Cannot perform the request") 88 | } 89 | 90 | if res.StatusCode != 200 { 91 | t.Fatalf("Invalid response status: %s", res.Status) 92 | } 93 | 94 | image, err := ioutil.ReadAll(res.Body) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | if len(image) == 0 { 99 | t.Fatalf("Empty response body") 100 | } 101 | 102 | err = assertSize(image, 300, 1080) 103 | if err != nil { 104 | t.Error(err) 105 | } 106 | 107 | if bimg.DetermineImageTypeName(image) != "jpeg" { 108 | t.Fatalf("Invalid image type") 109 | } 110 | } 111 | 112 | func TestEnlarge(t *testing.T) { 113 | ts := testServer(controller(Enlarge)) 114 | buf := readFile("large.jpg") 115 | url := ts.URL + "?width=300&height=200" 116 | defer ts.Close() 117 | 118 | res, err := http.Post(url, "image/jpeg", buf) 119 | if err != nil { 120 | t.Fatal("Cannot perform the request") 121 | } 122 | 123 | if res.StatusCode != 200 { 124 | t.Fatalf("Invalid response status: %s", res.Status) 125 | } 126 | 127 | image, err := ioutil.ReadAll(res.Body) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | if len(image) == 0 { 132 | t.Fatalf("Empty response body") 133 | } 134 | 135 | err = assertSize(image, 300, 200) 136 | if err != nil { 137 | t.Error(err) 138 | } 139 | 140 | if bimg.DetermineImageTypeName(image) != "jpeg" { 141 | t.Fatalf("Invalid image type") 142 | } 143 | } 144 | 145 | func TestExtract(t *testing.T) { 146 | ts := testServer(controller(Extract)) 147 | buf := readFile("large.jpg") 148 | url := ts.URL + "?top=100&left=100&areawidth=200&areaheight=120" 149 | defer ts.Close() 150 | 151 | res, err := http.Post(url, "image/jpeg", buf) 152 | if err != nil { 153 | t.Fatal("Cannot perform the request") 154 | } 155 | 156 | if res.StatusCode != 200 { 157 | t.Fatalf("Invalid response status: %s", res.Status) 158 | } 159 | 160 | image, err := ioutil.ReadAll(res.Body) 161 | if err != nil { 162 | t.Fatal(err) 163 | } 164 | if len(image) == 0 { 165 | t.Fatalf("Empty response body") 166 | } 167 | 168 | err = assertSize(image, 200, 120) 169 | if err != nil { 170 | t.Error(err) 171 | } 172 | 173 | if bimg.DetermineImageTypeName(image) != "jpeg" { 174 | t.Fatalf("Invalid image type") 175 | } 176 | } 177 | 178 | func TestTypeAuto(t *testing.T) { 179 | cases := []struct { 180 | acceptHeader string 181 | expected string 182 | }{ 183 | {"", "jpeg"}, 184 | {"image/webp,*/*", "webp"}, 185 | {"image/png,*/*", "png"}, 186 | {"image/webp;q=0.8,image/jpeg", "webp"}, 187 | {"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", "webp"}, // Chrome 188 | } 189 | 190 | for _, test := range cases { 191 | ts := testServer(controller(Crop)) 192 | buf := readFile("large.jpg") 193 | url := ts.URL + "?width=300&type=auto" 194 | defer ts.Close() 195 | 196 | req, _ := http.NewRequest(http.MethodPost, url, buf) 197 | req.Header.Add("Content-Type", "image/jpeg") 198 | req.Header.Add("Accept", test.acceptHeader) 199 | res, err := http.DefaultClient.Do(req) 200 | if err != nil { 201 | t.Fatal("Cannot perform the request") 202 | } 203 | 204 | if res.StatusCode != 200 { 205 | t.Fatalf("Invalid response status: %s", res.Status) 206 | } 207 | 208 | if res.Header.Get("Content-Length") == "" { 209 | t.Fatal("Empty content length response") 210 | } 211 | 212 | image, err := ioutil.ReadAll(res.Body) 213 | if err != nil { 214 | t.Fatal(err) 215 | } 216 | if len(image) == 0 { 217 | t.Fatalf("Empty response body") 218 | } 219 | 220 | err = assertSize(image, 300, 1080) 221 | if err != nil { 222 | t.Error(err) 223 | } 224 | 225 | if bimg.DetermineImageTypeName(image) != test.expected { 226 | t.Fatalf("Invalid image type") 227 | } 228 | 229 | if res.Header.Get("Vary") != "Accept" { 230 | t.Fatal("Vary header not set correctly") 231 | } 232 | } 233 | } 234 | 235 | func TestFit(t *testing.T) { 236 | var err error 237 | 238 | buf := readFile("large.jpg") 239 | original, _ := ioutil.ReadAll(buf) 240 | err = assertSize(original, 1920, 1080) 241 | if err != nil { 242 | t.Errorf("Reference image expecations weren't met") 243 | } 244 | 245 | ts := testServer(controller(Fit)) 246 | url := ts.URL + "?width=300&height=300" 247 | defer ts.Close() 248 | 249 | res, err := http.Post(url, "image/jpeg", bytes.NewReader(original)) 250 | if err != nil { 251 | t.Fatal("Cannot perform the request") 252 | } 253 | 254 | if res.StatusCode != 200 { 255 | t.Fatalf("Invalid response status: %s", res.Status) 256 | } 257 | 258 | image, err := ioutil.ReadAll(res.Body) 259 | if err != nil { 260 | t.Fatal(err) 261 | } 262 | if len(image) == 0 { 263 | t.Fatalf("Empty response body") 264 | } 265 | 266 | // The reference image has a ratio of 1.778, this should produce a height of 168.75 267 | err = assertSize(image, 300, 169) 268 | if err != nil { 269 | t.Error(err) 270 | } 271 | 272 | if bimg.DetermineImageTypeName(image) != "jpeg" { 273 | t.Fatalf("Invalid image type") 274 | } 275 | } 276 | 277 | func TestRemoteHTTPSource(t *testing.T) { 278 | opts := ServerOptions{EnableURLSource: true, MaxAllowedPixels: 18.0} 279 | fn := ImageMiddleware(opts)(Crop) 280 | LoadSources(opts) 281 | 282 | tsImage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 283 | buf, _ := ioutil.ReadFile("testdata/large.jpg") 284 | _, _ = w.Write(buf) 285 | })) 286 | defer tsImage.Close() 287 | 288 | ts := httptest.NewServer(fn) 289 | url := ts.URL + "?width=200&height=200&url=" + tsImage.URL 290 | defer ts.Close() 291 | 292 | res, err := http.Get(url) 293 | if err != nil { 294 | t.Fatal("Cannot perform the request") 295 | } 296 | if res.StatusCode != 200 { 297 | t.Fatalf("Invalid response status: %d", res.StatusCode) 298 | } 299 | 300 | image, err := ioutil.ReadAll(res.Body) 301 | if err != nil { 302 | t.Fatal(err) 303 | } 304 | if len(image) == 0 { 305 | t.Fatalf("Empty response body") 306 | } 307 | 308 | err = assertSize(image, 200, 200) 309 | if err != nil { 310 | t.Error(err) 311 | } 312 | 313 | if bimg.DetermineImageTypeName(image) != "jpeg" { 314 | t.Fatalf("Invalid image type") 315 | } 316 | } 317 | 318 | func TestInvalidRemoteHTTPSource(t *testing.T) { 319 | opts := ServerOptions{EnableURLSource: true, MaxAllowedPixels: 18.0} 320 | fn := ImageMiddleware(opts)(Crop) 321 | LoadSources(opts) 322 | 323 | tsImage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 324 | w.WriteHeader(400) 325 | })) 326 | defer tsImage.Close() 327 | 328 | ts := httptest.NewServer(fn) 329 | url := ts.URL + "?width=200&height=200&url=" + tsImage.URL 330 | defer ts.Close() 331 | 332 | res, err := http.Get(url) 333 | if err != nil { 334 | t.Fatal("Request failed") 335 | } 336 | if res.StatusCode != 400 { 337 | t.Fatalf("Invalid response status: %d", res.StatusCode) 338 | } 339 | } 340 | 341 | func TestMountDirectory(t *testing.T) { 342 | opts := ServerOptions{Mount: "testdata", MaxAllowedPixels: 18.0} 343 | fn := ImageMiddleware(opts)(Crop) 344 | LoadSources(opts) 345 | 346 | ts := httptest.NewServer(fn) 347 | url := ts.URL + "?width=200&height=200&file=large.jpg" 348 | defer ts.Close() 349 | 350 | res, err := http.Get(url) 351 | if err != nil { 352 | t.Fatal("Cannot perform the request") 353 | } 354 | if res.StatusCode != 200 { 355 | t.Fatalf("Invalid response status: %d", res.StatusCode) 356 | } 357 | 358 | image, err := ioutil.ReadAll(res.Body) 359 | if err != nil { 360 | t.Fatal(err) 361 | } 362 | if len(image) == 0 { 363 | t.Fatalf("Empty response body") 364 | } 365 | 366 | err = assertSize(image, 200, 200) 367 | if err != nil { 368 | t.Error(err) 369 | } 370 | 371 | if bimg.DetermineImageTypeName(image) != "jpeg" { 372 | t.Fatalf("Invalid image type") 373 | } 374 | } 375 | 376 | func TestMountInvalidDirectory(t *testing.T) { 377 | fn := ImageMiddleware(ServerOptions{Mount: "_invalid_", MaxAllowedPixels: 18.0})(Crop) 378 | ts := httptest.NewServer(fn) 379 | url := ts.URL + "?top=100&left=100&areawidth=200&areaheight=120&file=large.jpg" 380 | defer ts.Close() 381 | 382 | res, err := http.Get(url) 383 | if err != nil { 384 | t.Fatal("Cannot perform the request") 385 | } 386 | 387 | if res.StatusCode != 400 { 388 | t.Fatalf("Invalid response status: %d", res.StatusCode) 389 | } 390 | } 391 | 392 | func TestMountInvalidPath(t *testing.T) { 393 | fn := ImageMiddleware(ServerOptions{Mount: "_invalid_"})(Crop) 394 | ts := httptest.NewServer(fn) 395 | url := ts.URL + "?top=100&left=100&areawidth=200&areaheight=120&file=../../large.jpg" 396 | defer ts.Close() 397 | 398 | res, err := http.Get(url) 399 | if err != nil { 400 | t.Fatal("Cannot perform the request") 401 | } 402 | 403 | if res.StatusCode != 400 { 404 | t.Fatalf("Invalid response status: %s", res.Status) 405 | } 406 | } 407 | 408 | func controller(op Operation) func(w http.ResponseWriter, r *http.Request) { 409 | return func(w http.ResponseWriter, r *http.Request) { 410 | buf, _ := ioutil.ReadAll(r.Body) 411 | imageHandler(w, r, buf, op, ServerOptions{MaxAllowedPixels: 18.0}) 412 | } 413 | } 414 | 415 | func testServer(fn func(w http.ResponseWriter, r *http.Request)) *httptest.Server { 416 | return httptest.NewServer(http.HandlerFunc(fn)) 417 | } 418 | 419 | func readFile(file string) io.Reader { 420 | buf, _ := os.Open(path.Join("testdata", file)) 421 | return buf 422 | } 423 | 424 | func assertSize(buf []byte, width, height int) error { 425 | size, err := bimg.NewImage(buf).Size() 426 | if err != nil { 427 | return err 428 | } 429 | if size.Width != width || size.Height != height { 430 | return fmt.Errorf("invalid image size: %dx%d, expected: %dx%d", size.Width, size.Height, width, height) 431 | } 432 | return nil 433 | } 434 | -------------------------------------------------------------------------------- /source.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | ) 7 | 8 | type ImageSourceType string 9 | type ImageSourceFactoryFunction func(*SourceConfig) ImageSource 10 | 11 | type SourceConfig struct { 12 | AuthForwarding bool 13 | Authorization string 14 | MountPath string 15 | Type ImageSourceType 16 | ForwardHeaders []string 17 | AllowedOrigins []*url.URL 18 | MaxAllowedSize int 19 | } 20 | 21 | var imageSourceMap = make(map[ImageSourceType]ImageSource) 22 | var imageSourceFactoryMap = make(map[ImageSourceType]ImageSourceFactoryFunction) 23 | 24 | type ImageSource interface { 25 | Matches(*http.Request) bool 26 | GetImage(*http.Request) ([]byte, error) 27 | } 28 | 29 | func RegisterSource(sourceType ImageSourceType, factory ImageSourceFactoryFunction) { 30 | imageSourceFactoryMap[sourceType] = factory 31 | } 32 | 33 | func LoadSources(o ServerOptions) { 34 | for name, factory := range imageSourceFactoryMap { 35 | imageSourceMap[name] = factory(&SourceConfig{ 36 | Type: name, 37 | MountPath: o.Mount, 38 | AuthForwarding: o.AuthForwarding, 39 | Authorization: o.Authorization, 40 | AllowedOrigins: o.AllowedOrigins, 41 | MaxAllowedSize: o.MaxAllowedSize, 42 | ForwardHeaders: o.ForwardHeaders, 43 | }) 44 | } 45 | } 46 | 47 | func MatchSource(req *http.Request) ImageSource { 48 | for _, source := range imageSourceMap { 49 | if source.Matches(req) { 50 | return source 51 | } 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /source_body.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | const formFieldName = "file" 10 | const maxMemory int64 = 1024 * 1024 * 64 11 | 12 | const ImageSourceTypeBody ImageSourceType = "payload" 13 | 14 | type BodyImageSource struct { 15 | Config *SourceConfig 16 | } 17 | 18 | func NewBodyImageSource(config *SourceConfig) ImageSource { 19 | return &BodyImageSource{config} 20 | } 21 | 22 | func (s *BodyImageSource) Matches(r *http.Request) bool { 23 | return r.Method == http.MethodPost || r.Method == http.MethodPut 24 | } 25 | 26 | func (s *BodyImageSource) GetImage(r *http.Request) ([]byte, error) { 27 | if isFormBody(r) { 28 | return readFormBody(r) 29 | } 30 | return readRawBody(r) 31 | } 32 | 33 | func isFormBody(r *http.Request) bool { 34 | return strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") 35 | } 36 | 37 | func readFormBody(r *http.Request) ([]byte, error) { 38 | err := r.ParseMultipartForm(maxMemory) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | file, _, err := r.FormFile(formFieldName) 44 | if err != nil { 45 | return nil, err 46 | } 47 | defer file.Close() 48 | 49 | buf, err := ioutil.ReadAll(file) 50 | if len(buf) == 0 { 51 | err = ErrEmptyBody 52 | } 53 | 54 | return buf, err 55 | } 56 | 57 | func readRawBody(r *http.Request) ([]byte, error) { 58 | return ioutil.ReadAll(r.Body) 59 | } 60 | 61 | func init() { 62 | RegisterSource(ImageSourceTypeBody, NewBodyImageSource) 63 | } 64 | -------------------------------------------------------------------------------- /source_body_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "os" 9 | "testing" 10 | ) 11 | 12 | const fixtureFile = "testdata/large.jpg" 13 | 14 | func TestSourceBodyMatch(t *testing.T) { 15 | u, _ := url.Parse("http://foo") 16 | req := &http.Request{Method: http.MethodPost, URL: u} 17 | source := NewBodyImageSource(&SourceConfig{}) 18 | 19 | if !source.Matches(req) { 20 | t.Error("Cannot match the request") 21 | } 22 | } 23 | 24 | func TestBodyImageSource(t *testing.T) { 25 | var body []byte 26 | var err error 27 | 28 | source := NewBodyImageSource(&SourceConfig{}) 29 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 30 | if !source.Matches(r) { 31 | t.Fatal("Cannot match the request") 32 | } 33 | 34 | body, err = source.GetImage(r) 35 | if err != nil { 36 | t.Fatalf("Error while reading the body: %s", err) 37 | } 38 | _, _ = w.Write(body) 39 | } 40 | 41 | file, _ := os.Open(fixtureFile) 42 | r, _ := http.NewRequest(http.MethodPost, "http://foo/bar", file) 43 | w := httptest.NewRecorder() 44 | fakeHandler(w, r) 45 | 46 | buf, _ := ioutil.ReadFile(fixtureFile) 47 | if len(body) != len(buf) { 48 | t.Error("Invalid response body") 49 | } 50 | } 51 | 52 | func testReadBody(t *testing.T) { 53 | var body []byte 54 | var err error 55 | 56 | source := NewBodyImageSource(&SourceConfig{}) 57 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 58 | if !source.Matches(r) { 59 | t.Fatal("Cannot match the request") 60 | } 61 | 62 | body, err = source.GetImage(r) 63 | if err != nil { 64 | t.Fatalf("Error while reading the body: %s", err) 65 | } 66 | _, _ = w.Write(body) 67 | } 68 | 69 | file, _ := os.Open(fixtureFile) 70 | r, _ := http.NewRequest(http.MethodPost, "http://foo/bar", file) 71 | w := httptest.NewRecorder() 72 | fakeHandler(w, r) 73 | 74 | buf, _ := ioutil.ReadFile(fixtureFile) 75 | if len(body) != len(buf) { 76 | t.Error("Invalid response body") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /source_fs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | const ImageSourceTypeFileSystem ImageSourceType = "fs" 13 | 14 | type FileSystemImageSource struct { 15 | Config *SourceConfig 16 | } 17 | 18 | func NewFileSystemImageSource(config *SourceConfig) ImageSource { 19 | return &FileSystemImageSource{config} 20 | } 21 | 22 | func (s *FileSystemImageSource) Matches(r *http.Request) bool { 23 | file, err := s.getFileParam(r) 24 | if err != nil { 25 | return false 26 | } 27 | return r.Method == http.MethodGet && file != "" 28 | } 29 | 30 | func (s *FileSystemImageSource) GetImage(r *http.Request) ([]byte, error) { 31 | file, err := s.getFileParam(r) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | if file == "" { 37 | return nil, ErrMissingParamFile 38 | } 39 | 40 | file, err = s.buildPath(file) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return s.read(file) 46 | } 47 | 48 | func (s *FileSystemImageSource) buildPath(file string) (string, error) { 49 | file = path.Clean(path.Join(s.Config.MountPath, file)) 50 | if !strings.HasPrefix(file, s.Config.MountPath) { 51 | return "", ErrInvalidFilePath 52 | } 53 | return file, nil 54 | } 55 | 56 | func (s *FileSystemImageSource) read(file string) ([]byte, error) { 57 | buf, err := ioutil.ReadFile(file) 58 | if err != nil { 59 | return nil, ErrInvalidFilePath 60 | } 61 | return buf, nil 62 | } 63 | 64 | func (s *FileSystemImageSource) getFileParam(r *http.Request) (string, error) { 65 | unescaped, err := url.QueryUnescape(r.URL.Query().Get("file")) 66 | if err != nil{ 67 | return "", fmt.Errorf("failed to unescape file param: %w", err) 68 | } 69 | 70 | return unescaped, nil 71 | } 72 | 73 | func init() { 74 | RegisterSource(ImageSourceTypeFileSystem, NewFileSystemImageSource) 75 | } 76 | -------------------------------------------------------------------------------- /source_fs_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | func TestFileSystemImageSource(t *testing.T) { 12 | var body []byte 13 | var err error 14 | const fixtureFile = "testdata/large image.jpg" 15 | 16 | source := NewFileSystemImageSource(&SourceConfig{MountPath: "testdata"}) 17 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 18 | if !source.Matches(r) { 19 | t.Fatal("Cannot match the request") 20 | } 21 | 22 | body, err = source.GetImage(r) 23 | if err != nil { 24 | t.Fatalf("Error while reading the body: %s", err) 25 | } 26 | _, _ = w.Write(body) 27 | } 28 | 29 | file, _ := os.Open(fixtureFile) 30 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?file=large%20image.jpg", file) 31 | w := httptest.NewRecorder() 32 | fakeHandler(w, r) 33 | 34 | buf, _ := ioutil.ReadFile(fixtureFile) 35 | if len(body) != len(buf) { 36 | t.Error("Invalid response body") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /source_http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | const ImageSourceTypeHTTP ImageSourceType = "http" 13 | const URLQueryKey = "url" 14 | 15 | type HTTPImageSource struct { 16 | Config *SourceConfig 17 | } 18 | 19 | func NewHTTPImageSource(config *SourceConfig) ImageSource { 20 | return &HTTPImageSource{config} 21 | } 22 | 23 | func (s *HTTPImageSource) Matches(r *http.Request) bool { 24 | return r.Method == http.MethodGet && r.URL.Query().Get(URLQueryKey) != "" 25 | } 26 | 27 | func (s *HTTPImageSource) GetImage(req *http.Request) ([]byte, error) { 28 | u, err := parseURL(req) 29 | if err != nil { 30 | return nil, ErrInvalidImageURL 31 | } 32 | if shouldRestrictOrigin(u, s.Config.AllowedOrigins) { 33 | return nil, fmt.Errorf("not allowed remote URL origin: %s%s", u.Host, u.Path) 34 | } 35 | return s.fetchImage(u, req) 36 | } 37 | 38 | func (s *HTTPImageSource) fetchImage(url *url.URL, ireq *http.Request) ([]byte, error) { 39 | // Check remote image size by fetching HTTP Headers 40 | if s.Config.MaxAllowedSize > 0 { 41 | req := newHTTPRequest(s, ireq, http.MethodHead, url) 42 | res, err := http.DefaultClient.Do(req) 43 | if err != nil { 44 | return nil, fmt.Errorf("error fetching remote http image headers: %v", err) 45 | } 46 | _ = res.Body.Close() 47 | if res.StatusCode < 200 && res.StatusCode > 206 { 48 | return nil, NewError(fmt.Sprintf("error fetching remote http image headers: (status=%d) (url=%s)", res.StatusCode, req.URL.String()), res.StatusCode) 49 | } 50 | 51 | contentLength, _ := strconv.Atoi(res.Header.Get("Content-Length")) 52 | if contentLength > s.Config.MaxAllowedSize { 53 | return nil, fmt.Errorf("Content-Length %d exceeds maximum allowed %d bytes", contentLength, s.Config.MaxAllowedSize) 54 | } 55 | } 56 | 57 | // Perform the request using the default client 58 | req := newHTTPRequest(s, ireq, http.MethodGet, url) 59 | res, err := http.DefaultClient.Do(req) 60 | if err != nil { 61 | return nil, fmt.Errorf("error fetching remote http image: %v", err) 62 | } 63 | defer res.Body.Close() 64 | if res.StatusCode != 200 { 65 | return nil, NewError(fmt.Sprintf("error fetching remote http image: (status=%d) (url=%s)", res.StatusCode, req.URL.String()), res.StatusCode) 66 | } 67 | 68 | // Read the body 69 | buf, err := ioutil.ReadAll(res.Body) 70 | if err != nil { 71 | return nil, fmt.Errorf("unable to create image from response body: %s (url=%s)", req.URL.String(), err) 72 | } 73 | return buf, nil 74 | } 75 | 76 | func (s *HTTPImageSource) setAuthorizationHeader(req *http.Request, ireq *http.Request) { 77 | auth := s.Config.Authorization 78 | if auth == "" { 79 | auth = ireq.Header.Get("X-Forward-Authorization") 80 | } 81 | if auth == "" { 82 | auth = ireq.Header.Get("Authorization") 83 | } 84 | if auth != "" { 85 | req.Header.Set("Authorization", auth) 86 | } 87 | } 88 | 89 | func (s *HTTPImageSource) setForwardHeaders(req *http.Request, ireq *http.Request) { 90 | headers := s.Config.ForwardHeaders 91 | for _, header := range headers { 92 | if _, ok := ireq.Header[header]; ok { 93 | req.Header.Set(header, ireq.Header.Get(header)) 94 | } 95 | } 96 | } 97 | 98 | func parseURL(request *http.Request) (*url.URL, error) { 99 | return url.Parse(request.URL.Query().Get(URLQueryKey)) 100 | } 101 | 102 | func newHTTPRequest(s *HTTPImageSource, ireq *http.Request, method string, url *url.URL) *http.Request { 103 | req, _ := http.NewRequest(method, url.String(), nil) 104 | req.Header.Set("User-Agent", "imaginary/"+Version) 105 | req.URL = url 106 | 107 | if len(s.Config.ForwardHeaders) != 0 { 108 | s.setForwardHeaders(req, ireq) 109 | } 110 | 111 | // Forward auth header to the target server, if necessary 112 | if s.Config.AuthForwarding || s.Config.Authorization != "" { 113 | s.setAuthorizationHeader(req, ireq) 114 | } 115 | 116 | return req 117 | } 118 | 119 | func shouldRestrictOrigin(url *url.URL, origins []*url.URL) bool { 120 | if len(origins) == 0 { 121 | return false 122 | } 123 | 124 | for _, origin := range origins { 125 | if origin.Host == url.Host { 126 | if strings.HasPrefix(url.Path, origin.Path) { 127 | return false 128 | } 129 | } 130 | 131 | if origin.Host[0:2] == "*." { 132 | // Testing if "*.example.org" matches "example.org" 133 | if url.Host == origin.Host[2:] { 134 | if strings.HasPrefix(url.Path, origin.Path) { 135 | return false 136 | } 137 | } 138 | 139 | // Testing if "*.example.org" matches "foo.example.org" 140 | if strings.HasSuffix(url.Host, origin.Host[1:]) { 141 | if strings.HasPrefix(url.Path, origin.Path) { 142 | return false 143 | } 144 | } 145 | } 146 | } 147 | 148 | return true 149 | } 150 | 151 | func init() { 152 | RegisterSource(ImageSourceTypeHTTP, NewHTTPImageSource) 153 | } 154 | -------------------------------------------------------------------------------- /source_http_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | ) 10 | 11 | const fixtureImage = "testdata/large.jpg" 12 | const fixture1024Bytes = "testdata/1024bytes" 13 | 14 | func TestHttpImageSource(t *testing.T) { 15 | var body []byte 16 | var err error 17 | 18 | buf, _ := ioutil.ReadFile(fixtureImage) 19 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | _, _ = w.Write(buf) 21 | })) 22 | defer ts.Close() 23 | 24 | source := NewHTTPImageSource(&SourceConfig{}) 25 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 26 | if !source.Matches(r) { 27 | t.Fatal("Cannot match the request") 28 | } 29 | 30 | body, err = source.GetImage(r) 31 | if err != nil { 32 | t.Fatalf("Error while reading the body: %s", err) 33 | } 34 | _, _ = w.Write(body) 35 | } 36 | 37 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+ts.URL, nil) 38 | w := httptest.NewRecorder() 39 | fakeHandler(w, r) 40 | 41 | if len(body) != len(buf) { 42 | t.Error("Invalid response body") 43 | } 44 | } 45 | 46 | func TestHttpImageSourceAllowedOrigin(t *testing.T) { 47 | buf, _ := ioutil.ReadFile(fixtureImage) 48 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | _, _ = w.Write(buf) 50 | })) 51 | defer ts.Close() 52 | 53 | origin, _ := url.Parse(ts.URL) 54 | origins := []*url.URL{origin} 55 | source := NewHTTPImageSource(&SourceConfig{AllowedOrigins: origins}) 56 | 57 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 58 | if !source.Matches(r) { 59 | t.Fatal("Cannot match the request") 60 | } 61 | 62 | body, err := source.GetImage(r) 63 | if err != nil { 64 | t.Fatalf("Error while reading the body: %s", err) 65 | } 66 | _, _ = w.Write(body) 67 | 68 | if len(body) != len(buf) { 69 | t.Error("Invalid response body length") 70 | } 71 | } 72 | 73 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+ts.URL, nil) 74 | w := httptest.NewRecorder() 75 | fakeHandler(w, r) 76 | } 77 | 78 | func TestHttpImageSourceNotAllowedOrigin(t *testing.T) { 79 | origin, _ := url.Parse("http://foo") 80 | origins := []*url.URL{origin} 81 | source := NewHTTPImageSource(&SourceConfig{AllowedOrigins: origins}) 82 | 83 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 84 | if !source.Matches(r) { 85 | t.Fatal("Cannot match the request") 86 | } 87 | 88 | _, err := source.GetImage(r) 89 | if err == nil { 90 | t.Fatal("Error cannot be empty") 91 | } 92 | 93 | if err.Error() != "not allowed remote URL origin: bar.com" { 94 | t.Fatalf("Invalid error message: %s", err) 95 | } 96 | } 97 | 98 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url=http://bar.com", nil) 99 | w := httptest.NewRecorder() 100 | fakeHandler(w, r) 101 | } 102 | 103 | func TestHttpImageSourceForwardAuthHeader(t *testing.T) { 104 | cases := []string{ 105 | "X-Forward-Authorization", 106 | "Authorization", 107 | } 108 | 109 | for _, header := range cases { 110 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url=http://bar.com", nil) 111 | r.Header.Set(header, "foobar") 112 | 113 | source := &HTTPImageSource{&SourceConfig{AuthForwarding: true}} 114 | if !source.Matches(r) { 115 | t.Fatal("Cannot match the request") 116 | } 117 | 118 | oreq := &http.Request{Header: make(http.Header)} 119 | source.setAuthorizationHeader(oreq, r) 120 | 121 | if oreq.Header.Get("Authorization") != "foobar" { 122 | t.Fatal("Mismatch Authorization header") 123 | } 124 | } 125 | } 126 | 127 | func TestHttpImageSourceForwardHeaders(t *testing.T) { 128 | cases := []string{ 129 | "X-Custom", 130 | "X-Token", 131 | } 132 | 133 | for _, header := range cases { 134 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url=http://bar.com", nil) 135 | r.Header.Set(header, "foobar") 136 | 137 | source := &HTTPImageSource{&SourceConfig{ForwardHeaders: cases}} 138 | if !source.Matches(r) { 139 | t.Fatal("Cannot match the request") 140 | } 141 | 142 | oreq := &http.Request{Header: make(http.Header)} 143 | source.setForwardHeaders(oreq, r) 144 | 145 | if oreq.Header.Get(header) != "foobar" { 146 | t.Fatal("Mismatch custom header") 147 | } 148 | } 149 | } 150 | 151 | func TestHttpImageSourceNotForwardHeaders(t *testing.T) { 152 | cases := []string{ 153 | "X-Custom", 154 | "X-Token", 155 | } 156 | 157 | testURL := createURL("http://bar.com", t) 158 | 159 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+testURL.String(), nil) 160 | r.Header.Set("Not-Forward", "foobar") 161 | 162 | source := &HTTPImageSource{&SourceConfig{ForwardHeaders: cases}} 163 | if !source.Matches(r) { 164 | t.Fatal("Cannot match the request") 165 | } 166 | 167 | oreq := newHTTPRequest(source, r, http.MethodGet, testURL) 168 | 169 | if oreq.Header.Get("Not-Forward") != "" { 170 | t.Fatal("Forwarded unspecified header") 171 | } 172 | } 173 | 174 | func TestHttpImageSourceForwardedHeadersNotOverride(t *testing.T) { 175 | cases := []string{ 176 | "Authorization", 177 | "X-Custom", 178 | } 179 | 180 | testURL := createURL("http://bar.com", t) 181 | 182 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+testURL.String(), nil) 183 | r.Header.Set("Authorization", "foobar") 184 | 185 | source := &HTTPImageSource{&SourceConfig{Authorization: "ValidAPIKey", ForwardHeaders: cases}} 186 | if !source.Matches(r) { 187 | t.Fatal("Cannot match the request") 188 | } 189 | 190 | oreq := newHTTPRequest(source, r, http.MethodGet, testURL) 191 | 192 | if oreq.Header.Get("Authorization") != "ValidAPIKey" { 193 | t.Fatal("Authorization header override") 194 | } 195 | } 196 | 197 | func TestHttpImageSourceCaseSensitivityInForwardedHeaders(t *testing.T) { 198 | cases := []string{ 199 | "X-Custom", 200 | "X-Token", 201 | } 202 | 203 | testURL := createURL("http://bar.com", t) 204 | 205 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+testURL.String(), nil) 206 | r.Header.Set("x-custom", "foobar") 207 | 208 | source := &HTTPImageSource{&SourceConfig{ForwardHeaders: cases}} 209 | if !source.Matches(r) { 210 | t.Fatal("Cannot match the request") 211 | } 212 | 213 | oreq := newHTTPRequest(source, r, http.MethodGet, testURL) 214 | 215 | if oreq.Header.Get("X-Custom") == "" { 216 | t.Fatal("Case sensitive not working on forwarded headers") 217 | } 218 | } 219 | 220 | func TestHttpImageSourceEmptyForwardedHeaders(t *testing.T) { 221 | var cases []string 222 | 223 | testURL := createURL("http://bar.com", t) 224 | 225 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+testURL.String(), nil) 226 | 227 | source := &HTTPImageSource{&SourceConfig{ForwardHeaders: cases}} 228 | if !source.Matches(r) { 229 | t.Fatal("Cannot match the request") 230 | } 231 | 232 | if len(source.Config.ForwardHeaders) != 0 { 233 | t.Log(source.Config.ForwardHeaders) 234 | t.Fatal("Set empty custom header") 235 | } 236 | 237 | oreq := newHTTPRequest(source, r, http.MethodGet, testURL) 238 | 239 | if oreq == nil { 240 | t.Fatal("Error creating request using empty custom headers") 241 | } 242 | } 243 | 244 | func TestHttpImageSourceError(t *testing.T) { 245 | var err error 246 | 247 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 248 | w.WriteHeader(404) 249 | _, _ = w.Write([]byte("Not found")) 250 | })) 251 | defer ts.Close() 252 | 253 | source := NewHTTPImageSource(&SourceConfig{}) 254 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 255 | if !source.Matches(r) { 256 | t.Fatal("Cannot match the request") 257 | } 258 | 259 | _, err = source.GetImage(r) 260 | if err == nil { 261 | t.Fatalf("Server response should not be valid: %s", err) 262 | } 263 | } 264 | 265 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+ts.URL, nil) 266 | w := httptest.NewRecorder() 267 | fakeHandler(w, r) 268 | } 269 | 270 | func TestHttpImageSourceExceedsMaximumAllowedLength(t *testing.T) { 271 | var body []byte 272 | var err error 273 | 274 | buf, _ := ioutil.ReadFile(fixture1024Bytes) 275 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 276 | _, _ = w.Write(buf) 277 | })) 278 | defer ts.Close() 279 | 280 | source := NewHTTPImageSource(&SourceConfig{ 281 | MaxAllowedSize: 1023, 282 | }) 283 | fakeHandler := func(w http.ResponseWriter, r *http.Request) { 284 | if !source.Matches(r) { 285 | t.Fatal("Cannot match the request") 286 | } 287 | 288 | body, err = source.GetImage(r) 289 | if err == nil { 290 | t.Fatalf("It should not allow a request to image exceeding maximum allowed size: %s", err) 291 | } 292 | _, _ = w.Write(body) 293 | } 294 | 295 | r, _ := http.NewRequest(http.MethodGet, "http://foo/bar?url="+ts.URL, nil) 296 | w := httptest.NewRecorder() 297 | fakeHandler(w, r) 298 | } 299 | 300 | func TestShouldRestrictOrigin(t *testing.T) { 301 | plainOrigins := parseOrigins( 302 | "https://example.org", 303 | ) 304 | 305 | wildCardOrigins := parseOrigins( 306 | "https://localhost,https://*.example.org,https://some.s3.bucket.on.aws.org,https://*.s3.bucket.on.aws.org", 307 | ) 308 | 309 | withPathOrigins := parseOrigins( 310 | "https://localhost/foo/bar/,https://*.example.org/foo/,https://some.s3.bucket.on.aws.org/my/bucket/," + 311 | "https://*.s3.bucket.on.aws.org/my/bucket/,https://no-leading-path-slash.example.org/assets", 312 | ) 313 | 314 | with2Buckets := parseOrigins( 315 | "https://some.s3.bucket.on.aws.org/my/bucket1/,https://some.s3.bucket.on.aws.org/my/bucket2/", 316 | ) 317 | 318 | pathWildCard := parseOrigins( 319 | "https://some.s3.bucket.on.aws.org/my-bucket-name*", 320 | ) 321 | 322 | t.Run("Plain origin", func(t *testing.T) { 323 | testURL := createURL("https://example.org/logo.jpg", t) 324 | 325 | if shouldRestrictOrigin(testURL, plainOrigins) { 326 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, plainOrigins) 327 | } 328 | }) 329 | 330 | t.Run("Wildcard origin, plain URL", func(t *testing.T) { 331 | testURL := createURL("https://example.org/logo.jpg", t) 332 | 333 | if shouldRestrictOrigin(testURL, wildCardOrigins) { 334 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, wildCardOrigins) 335 | } 336 | }) 337 | 338 | t.Run("Wildcard origin, sub domain URL", func(t *testing.T) { 339 | testURL := createURL("https://node-42.example.org/logo.jpg", t) 340 | 341 | if shouldRestrictOrigin(testURL, wildCardOrigins) { 342 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, wildCardOrigins) 343 | } 344 | }) 345 | 346 | t.Run("Wildcard origin, sub-sub domain URL", func(t *testing.T) { 347 | testURL := createURL("https://n.s3.bucket.on.aws.org/our/bucket/logo.jpg", t) 348 | 349 | if shouldRestrictOrigin(testURL, wildCardOrigins) { 350 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, wildCardOrigins) 351 | } 352 | }) 353 | 354 | t.Run("Wildcard origin, incorrect domain URL", func(t *testing.T) { 355 | testURL := createURL("https://myexample.org/logo.jpg", t) 356 | 357 | if !shouldRestrictOrigin(testURL, plainOrigins) { 358 | t.Errorf("Expected '%s' to not be allowed with plain origins: %+v", testURL, plainOrigins) 359 | } 360 | 361 | if !shouldRestrictOrigin(testURL, wildCardOrigins) { 362 | t.Errorf("Expected '%s' to not be allowed with wildcard origins: %+v", testURL, wildCardOrigins) 363 | } 364 | }) 365 | 366 | t.Run("Loopback origin with path, correct URL", func(t *testing.T) { 367 | testURL := createURL("https://localhost/foo/bar/logo.png", t) 368 | 369 | if shouldRestrictOrigin(testURL, withPathOrigins) { 370 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, withPathOrigins) 371 | } 372 | }) 373 | 374 | t.Run("Wildcard origin with path, correct URL", func(t *testing.T) { 375 | testURL := createURL("https://our.company.s3.bucket.on.aws.org/my/bucket/logo.gif", t) 376 | 377 | if shouldRestrictOrigin(testURL, withPathOrigins) { 378 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, withPathOrigins) 379 | } 380 | }) 381 | 382 | t.Run("Wildcard origin with partial path, correct URL", func(t *testing.T) { 383 | testURL := createURL("https://our.company.s3.bucket.on.aws.org/my/bucket/a/b/c/d/e/logo.gif", t) 384 | 385 | if shouldRestrictOrigin(testURL, withPathOrigins) { 386 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, withPathOrigins) 387 | } 388 | }) 389 | 390 | t.Run("Wildcard origin with partial path, correct URL double slashes", func(t *testing.T) { 391 | testURL := createURL("https://static.example.org/foo//a//b//c/d/e/logo.webp", t) 392 | 393 | if shouldRestrictOrigin(testURL, withPathOrigins) { 394 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, withPathOrigins) 395 | } 396 | }) 397 | 398 | t.Run("Wildcard origin with path missing trailing slash, correct URL", func(t *testing.T) { 399 | testURL := createURL("https://no-leading-path-slash.example.org/assets/logo.webp", t) 400 | 401 | if shouldRestrictOrigin(testURL, parseOrigins("https://*.example.org/assets")) { 402 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, withPathOrigins) 403 | } 404 | }) 405 | 406 | t.Run("Loopback origin with path, incorrect URL", func(t *testing.T) { 407 | testURL := createURL("https://localhost/wrong/logo.png", t) 408 | 409 | if !shouldRestrictOrigin(testURL, withPathOrigins) { 410 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, withPathOrigins) 411 | } 412 | }) 413 | 414 | t.Run("2 buckets, bucket1", func(t *testing.T) { 415 | testURL := createURL("https://some.s3.bucket.on.aws.org/my/bucket1/logo.jpg", t) 416 | 417 | if shouldRestrictOrigin(testURL, with2Buckets) { 418 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, with2Buckets) 419 | } 420 | }) 421 | 422 | t.Run("2 buckets, bucket2", func(t *testing.T) { 423 | testURL := createURL("https://some.s3.bucket.on.aws.org/my/bucket2/logo.jpg", t) 424 | 425 | if shouldRestrictOrigin(testURL, with2Buckets) { 426 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, with2Buckets) 427 | } 428 | }) 429 | 430 | t.Run("Path wildcard", func(t *testing.T) { 431 | testURL := createURL("https://some.s3.bucket.on.aws.org/my-bucket-name/logo.jpg", t) 432 | testURLFail := createURL("https://some.s3.bucket.on.aws.org/my-other-bucket-name/logo.jpg", t) 433 | 434 | if shouldRestrictOrigin(testURL, pathWildCard) { 435 | t.Errorf("Expected '%s' to be allowed with origins: %+v", testURL, pathWildCard) 436 | } 437 | 438 | if !shouldRestrictOrigin(testURLFail, pathWildCard) { 439 | t.Errorf("Expected '%s' to be restricted with origins: %+v", testURLFail, pathWildCard) 440 | } 441 | }) 442 | 443 | } 444 | 445 | func TestParseOrigins(t *testing.T) { 446 | t.Run("Appending a trailing slash on paths", func(t *testing.T) { 447 | origins := parseOrigins("http://foo.example.org/assets") 448 | if origins[0].Path != "/assets/" { 449 | t.Errorf("Expected the path to have a trailing /, instead it was: %q", origins[0].Path) 450 | } 451 | }) 452 | 453 | t.Run("Paths should not receive multiple trailing slashes", func(t *testing.T) { 454 | origins := parseOrigins("http://foo.example.org/assets/") 455 | if origins[0].Path != "/assets/" { 456 | t.Errorf("Expected the path to have a single trailing /, instead it was: %q", origins[0].Path) 457 | } 458 | }) 459 | 460 | t.Run("Empty paths are fine", func(t *testing.T) { 461 | origins := parseOrigins("http://foo.example.org") 462 | if origins[0].Path != "" { 463 | t.Errorf("Expected the path to remain empty, instead it was: %q", origins[0].Path) 464 | } 465 | }) 466 | 467 | t.Run("Root paths are fine", func(t *testing.T) { 468 | origins := parseOrigins("http://foo.example.org/") 469 | if origins[0].Path != "/" { 470 | t.Errorf("Expected the path to remain a slash, instead it was: %q", origins[0].Path) 471 | } 472 | }) 473 | } 474 | 475 | func createURL(urlStr string, t *testing.T) *url.URL { 476 | t.Helper() 477 | 478 | result, err := url.Parse(urlStr) 479 | 480 | if err != nil { 481 | t.Error("Test setup failed, unable to parse test URL") 482 | } 483 | 484 | return result 485 | } 486 | -------------------------------------------------------------------------------- /source_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | ) 8 | 9 | func TestMatchSource(t *testing.T) { 10 | u, _ := url.Parse("http://foo?url=http://bar/image.jpg") 11 | req := &http.Request{Method: http.MethodGet, URL: u} 12 | 13 | source := MatchSource(req) 14 | if source == nil { 15 | t.Error("Cannot match image source") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /testdata/1024bytes: -------------------------------------------------------------------------------- 1 | 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 -------------------------------------------------------------------------------- /testdata/flyio-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /testdata/imaginary.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/imaginary/1d4e251cfcd58ea66f8361f8721d7b8cc85002a3/testdata/imaginary.jpg -------------------------------------------------------------------------------- /testdata/large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/imaginary/1d4e251cfcd58ea66f8361f8721d7b8cc85002a3/testdata/large.jpg -------------------------------------------------------------------------------- /testdata/medium.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/imaginary/1d4e251cfcd58ea66f8361f8721d7b8cc85002a3/testdata/medium.jpg -------------------------------------------------------------------------------- /testdata/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICjzCCAfgCCQCYQsNXSKvPPTANBgkqhkiG9w0BAQUFADCBizELMAkGA1UEBhMC 3 | SUUxDzANBgNVBAgTBkR1YmxpbjEPMA0GA1UEBxMGRHVibGluMSEwHwYDVQQKExhJ 4 | bnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFTATBgNVBAMTDGltYWdpbmFyeS5pbzEg 5 | MB4GCSqGSIb3DQEJARYRdG9tYXNAYXBhcmljaW8ubWUwHhcNMTUwNzExMTk0MTM2 6 | WhcNMjcwNjIzMTk0MTM2WjCBizELMAkGA1UEBhMCSUUxDzANBgNVBAgTBkR1Ymxp 7 | bjEPMA0GA1UEBxMGRHVibGluMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0 8 | eSBMdGQxFTATBgNVBAMTDGltYWdpbmFyeS5pbzEgMB4GCSqGSIb3DQEJARYRdG9t 9 | YXNAYXBhcmljaW8ubWUwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAKv846TF 10 | luaJi9lSpQxk3lwfU32gCpaZHysGmAtEkYzLCWKXV212AcKzW6v07lKD7w7mJ7Fr 11 | RTMNT++tcBDNL4RAVdyLhYhHIabmWOh85cPaWwM+6tE9JxlQKQi6qYE2P7sE4D9f 12 | EjIGi7wnBOsXNHCWpQExmkY1g3GYiCsBa3QTAgMBAAEwDQYJKoZIhvcNAQEFBQAD 13 | gYEAHxWFoEQh4/bzKc9ByGLdPubfRkck7mnA37leJO/ilooS7ZL22BW/yjzlP3dM 14 | LzCMFmBBNqHwPQMfnFWqoIAaHFa6FPgCZExZZ+xYfRxfatnVI2t11lQXZOUe8Dxf 15 | pQcjzecXFSMlhSXNQPYyZZzUjCUOgZDs0HSTvvRAStQjENU= 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /testdata/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQCr/OOkxZbmiYvZUqUMZN5cH1N9oAqWmR8rBpgLRJGMywlil1dt 3 | dgHCs1ur9O5Sg+8O5iexa0UzDU/vrXAQzS+EQFXci4WIRyGm5ljofOXD2lsDPurR 4 | PScZUCkIuqmBNj+7BOA/XxIyBou8JwTrFzRwlqUBMZpGNYNxmIgrAWt0EwIDAQAB 5 | AoGAXmSYiDmN3Y+GOst6HHhL9iGXUC6DQS5fBd1Dm4ORosVYrEzFxiTrSHHqEVGH 6 | b7BLh1DYXi6ytxdKVRBKnl4PAk9NUsdWdSFSvOv6wUM/uLMpKLYEdPRHcaHNSIca 7 | sz5ryRwA9zogS76Ke20tgPC6IklTVaEkbvfxG+ob7Vp53rECQQDacScoyDVsadlS 8 | 8oQqogO+XuTwgyk4UDd7K6KmMq0etS00SpPtlp1XvP90kqa+1OCAwjP8aUW8Bj+Q 9 | hbNa6yD5AkEAyY8H12He86EXFatKyrIRaQ71aMtgTOQ4cnH0l8EYKuDpKLA8mUdT 10 | skzu4EKxyt/6pg4yGwGzK+ruGH6J96EMawJAB82E7ZMBPYcmaS0ahX9WDOXM3b6B 11 | qW5MHQ04+SDUSEWGgNitIg6APlMU+PAIHsbx4geN3dVQ1V+Pw7TS7Et72QJAHepT 12 | sJz/GUvcgEPXKvR47w3gULh2x5LL6fiN5AQt0Rdmo7pclCdo/bq7bZ+YgdLygbjz 13 | qNx8ulT5F7uYQJ+vlwJBANb7kNO0bFFErtIFRuGpwJvzglUeWNz+Y2JQ/IeGm+wy 14 | lxIvpYYbJArqZjUochqaqoNBkBXYAOToTuYSLH/rAYE= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /testdata/smart-crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/imaginary/1d4e251cfcd58ea66f8361f8721d7b8cc85002a3/testdata/smart-crop.jpg -------------------------------------------------------------------------------- /testdata/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/imaginary/1d4e251cfcd58ea66f8361f8721d7b8cc85002a3/testdata/test.png -------------------------------------------------------------------------------- /testdata/test.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h2non/imaginary/1d4e251cfcd58ea66f8361f8721d7b8cc85002a3/testdata/test.webp -------------------------------------------------------------------------------- /type.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/h2non/bimg" 7 | ) 8 | 9 | // ExtractImageTypeFromMime returns the MIME image type. 10 | func ExtractImageTypeFromMime(mime string) string { 11 | mime = strings.Split(mime, ";")[0] 12 | parts := strings.Split(mime, "/") 13 | if len(parts) < 2 { 14 | return "" 15 | } 16 | name := strings.Split(parts[1], "+")[0] 17 | return strings.ToLower(name) 18 | } 19 | 20 | // IsImageMimeTypeSupported returns true if the image MIME 21 | // type is supported by bimg. 22 | func IsImageMimeTypeSupported(mime string) bool { 23 | format := ExtractImageTypeFromMime(mime) 24 | 25 | // Some payloads may expose the MIME type for SVG as text/xml 26 | if format == "xml" { 27 | format = "svg" 28 | } 29 | 30 | return bimg.IsTypeNameSupported(format) 31 | } 32 | 33 | // ImageType returns the image type based on the given image type alias. 34 | func ImageType(name string) bimg.ImageType { 35 | switch strings.ToLower(name) { 36 | case "jpeg": 37 | return bimg.JPEG 38 | case "png": 39 | return bimg.PNG 40 | case "webp": 41 | return bimg.WEBP 42 | case "tiff": 43 | return bimg.TIFF 44 | case "gif": 45 | return bimg.GIF 46 | case "svg": 47 | return bimg.SVG 48 | case "pdf": 49 | return bimg.PDF 50 | default: 51 | return bimg.UNKNOWN 52 | } 53 | } 54 | 55 | // GetImageMimeType returns the MIME type based on the given image type code. 56 | func GetImageMimeType(code bimg.ImageType) string { 57 | switch code { 58 | case bimg.PNG: 59 | return "image/png" 60 | case bimg.WEBP: 61 | return "image/webp" 62 | case bimg.TIFF: 63 | return "image/tiff" 64 | case bimg.GIF: 65 | return "image/gif" 66 | case bimg.SVG: 67 | return "image/svg+xml" 68 | case bimg.PDF: 69 | return "application/pdf" 70 | default: 71 | return "image/jpeg" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /type_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/h2non/bimg" 7 | ) 8 | 9 | func TestExtractImageTypeFromMime(t *testing.T) { 10 | files := []struct { 11 | mime string 12 | expected string 13 | }{ 14 | {"image/jpeg", "jpeg"}, 15 | {"/png", "png"}, 16 | {"png", ""}, 17 | {"multipart/form-data; encoding=utf-8", "form-data"}, 18 | {"", ""}, 19 | } 20 | 21 | for _, file := range files { 22 | if ExtractImageTypeFromMime(file.mime) != file.expected { 23 | t.Fatalf("Invalid mime type: %s != %s", file.mime, file.expected) 24 | } 25 | } 26 | } 27 | 28 | func TestIsImageTypeSupported(t *testing.T) { 29 | files := []struct { 30 | name string 31 | expected bool 32 | }{ 33 | {"image/jpeg", true}, 34 | {"image/png", true}, 35 | {"image/webp", true}, 36 | {"IMAGE/JPEG", true}, 37 | {"png", false}, 38 | {"multipart/form-data; encoding=utf-8", false}, 39 | {"application/json", false}, 40 | {"image/gif", bimg.IsImageTypeSupportedByVips(bimg.GIF).Load}, 41 | {"image/svg+xml", bimg.IsImageTypeSupportedByVips(bimg.SVG).Load}, 42 | {"image/svg", bimg.IsImageTypeSupportedByVips(bimg.SVG).Load}, 43 | {"image/tiff", bimg.IsImageTypeSupportedByVips(bimg.TIFF).Load}, 44 | {"application/pdf", bimg.IsImageTypeSupportedByVips(bimg.PDF).Load}, 45 | {"text/plain", false}, 46 | {"blablabla", false}, 47 | {"", false}, 48 | } 49 | 50 | for _, file := range files { 51 | if IsImageMimeTypeSupported(file.name) != file.expected { 52 | t.Fatalf("Invalid type: %s != %t", file.name, file.expected) 53 | } 54 | } 55 | } 56 | 57 | func TestImageType(t *testing.T) { 58 | files := []struct { 59 | name string 60 | expected bimg.ImageType 61 | }{ 62 | {"jpeg", bimg.JPEG}, 63 | {"png", bimg.PNG}, 64 | {"webp", bimg.WEBP}, 65 | {"tiff", bimg.TIFF}, 66 | {"gif", bimg.GIF}, 67 | {"svg", bimg.SVG}, 68 | {"pdf", bimg.PDF}, 69 | {"multipart/form-data; encoding=utf-8", bimg.UNKNOWN}, 70 | {"json", bimg.UNKNOWN}, 71 | {"text", bimg.UNKNOWN}, 72 | {"blablabla", bimg.UNKNOWN}, 73 | {"", bimg.UNKNOWN}, 74 | } 75 | 76 | for _, file := range files { 77 | if ImageType(file.name) != file.expected { 78 | t.Fatalf("Invalid type: %s != %s", file.name, bimg.ImageTypes[file.expected]) 79 | } 80 | } 81 | } 82 | 83 | func TestGetImageMimeType(t *testing.T) { 84 | files := []struct { 85 | name bimg.ImageType 86 | expected string 87 | }{ 88 | {bimg.JPEG, "image/jpeg"}, 89 | {bimg.PNG, "image/png"}, 90 | {bimg.WEBP, "image/webp"}, 91 | {bimg.TIFF, "image/tiff"}, 92 | {bimg.GIF, "image/gif"}, 93 | {bimg.PDF, "application/pdf"}, 94 | {bimg.SVG, "image/svg+xml"}, 95 | {bimg.UNKNOWN, "image/jpeg"}, 96 | } 97 | 98 | for _, file := range files { 99 | if GetImageMimeType(file.name) != file.expected { 100 | t.Fatalf("Invalid type: %s != %s", bimg.ImageTypes[file.name], file.expected) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Version stores the current package semantic version 4 | var Version = "dev" 5 | 6 | // Versions represents the used versions for several significant dependencies 7 | type Versions struct { 8 | ImaginaryVersion string `json:"imaginary"` 9 | BimgVersion string `json:"bimg"` 10 | VipsVersion string `json:"libvips"` 11 | } 12 | --------------------------------------------------------------------------------