diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..dae95c3d --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,49 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/go/.devcontainer/base.Dockerfile + +# [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 +ARG VARIANT="1.17-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT} + +# Versions of libvips and golanci-lint +ARG LIBVIPS_VERSION=8.12.2 +ARG GOLANGCILINT_VERSION=1.29.0 + +# Install additional OS packages +RUN DEBIAN_FRONTEND=noninteractive \ + apt-get update && \ + apt-get install --no-install-recommends -y \ + ca-certificates \ + automake build-essential curl \ + procps libopenexr25 libmagickwand-6.q16-6 libpango1.0-0 libmatio11 \ + libopenslide0 libjemalloc2 gobject-introspection gtk-doc-tools \ + libglib2.0-0 libglib2.0-dev libjpeg62-turbo libjpeg62-turbo-dev \ + libpng16-16 libpng-dev libwebp6 libwebpmux3 libwebpdemux2 libwebp-dev \ + libtiff5 libtiff5-dev libgif7 libgif-dev libexif12 libexif-dev \ + libxml2 libxml2-dev libpoppler-glib8 libpoppler-glib-dev \ + swig libmagickwand-dev libpango1.0-dev libmatio-dev libopenslide-dev \ + libcfitsio9 libcfitsio-dev libgsf-1-114 libgsf-1-dev fftw3 fftw3-dev \ + liborc-0.4-0 liborc-0.4-dev librsvg2-2 librsvg2-dev libimagequant0 \ + libimagequant-dev libheif1 libheif-dev && \ + cd /tmp && \ + curl -fsSLO https://github.com/libvips/libvips/releases/download/v${LIBVIPS_VERSION}/vips-${LIBVIPS_VERSION}.tar.gz && \ + tar zvxf vips-${LIBVIPS_VERSION}.tar.gz && \ + cd /tmp/vips-${LIBVIPS_VERSION} && \ + CFLAGS="-g -O3" CXXFLAGS="-D_GLIBCXX_USE_CXX11_ABI=0 -g -O3" \ + ./configure \ + --disable-debug \ + --disable-dependency-tracking \ + --disable-introspection \ + --disable-static \ + --enable-gtk-doc-html=no \ + --enable-gtk-doc=no \ + --enable-pyvips8=no && \ + make && \ + make install && \ + ldconfig + +# Installing golangci-lint +RUN curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "${GOPATH}/bin" v${GOLANGCILINT_VERSION} + +# [Optional] Uncomment the next lines to use go get to install anything else you need +# USER vscode +# RUN go get -x diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..5b7ee0cf --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,40 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/go +{ + "name": "Go", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.17 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local arm64/Apple Silicon. + "VARIANT": "1.17-bullseye" + } + }, + "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], + + // Set *default* container specific settings.json values on container create. + "settings": { + "go.toolsManagement.checkForUpdates": "local", + "go.useLanguageServer": true, + "go.gopath": "/go", + "go.goroot": "/usr/local/go" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "golang.Go" + ], + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [9000], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "go version", + + // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode", + "features": { + "docker-from-docker": "latest" + } +} diff --git a/.dockerignore b/.dockerignore index 8b5d8542..b869eedf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,6 @@ **/*~ +.devcontainer +.github .git +Dockerfile +docker-compose.yml diff --git a/Dockerfile b/Dockerfile index c72ff00b..3baac202 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -ARG GOLANG_VERSION=1.14 -FROM golang:${GOLANG_VERSION} as builder +ARG GOLANG_VERSION=1.17 +FROM golang:${GOLANG_VERSION}-bullseye as builder ARG IMAGINARY_VERSION=dev -ARG LIBVIPS_VERSION=8.10.0 +ARG LIBVIPS_VERSION=8.12.2 ARG GOLANGCILINT_VERSION=1.29.0 # Installs libvips + required libraries @@ -51,7 +51,7 @@ RUN go mod download COPY . . # Run quality control -RUN go test -test.v -test.race -test.covermode=atomic . +RUN go test ./... -test.v -race -test.coverprofile=atomic . RUN golangci-lint run . # Compile imaginary @@ -60,7 +60,7 @@ RUN go build -a \ -ldflags="-s -w -h -X main.Version=${IMAGINARY_VERSION}" \ github.com/h2non/imaginary -FROM debian:buster-slim +FROM debian:bullseye-slim ARG IMAGINARY_VERSION @@ -79,15 +79,17 @@ COPY --from=builder /etc/ssl/certs /etc/ssl/certs RUN DEBIAN_FRONTEND=noninteractive \ apt-get update && \ apt-get install --no-install-recommends -y \ - procps libglib2.0-0 libjpeg62-turbo libpng16-16 libopenexr23 \ + procps libglib2.0-0 libjpeg62-turbo libpng16-16 libopenexr25 \ libwebp6 libwebpmux3 libwebpdemux2 libtiff5 libgif7 libexif12 libxml2 libpoppler-glib8 \ - libmagickwand-6.q16-6 libpango1.0-0 libmatio4 libopenslide0 \ - libgsf-1-114 fftw3 liborc-0.4-0 librsvg2-2 libcfitsio7 libimagequant0 libheif1 imagemagick ghostscript && \ + libmagickwand-6.q16-6 libpango1.0-0 libmatio11 libopenslide0 libjemalloc2 \ + libgsf-1-114 fftw3 liborc-0.4-0 librsvg2-2 libcfitsio9 libimagequant0 libheif1 imagemagick ghostscript && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ apt-get autoremove -y && \ apt-get autoclean && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \ sed -i 's/ + @@ -200,6 +201,12 @@ Start the application cf start imaginary-inst01 ``` +### Google Cloud Run + +Click to deploy on Google Cloud Run: + +[![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) + ### Recommended resources Given the multithreaded native nature of Go, in terms of CPUs, most cores means more concurrency and therefore, a better performance can be achieved. @@ -222,7 +229,7 @@ $ imaginary -concurrency 20 ### Memory issues -In case you are experiencing any persistent unreleased memory issues in your deployment, you can try passing this environemnt variables to `imaginary`: +In case you are experiencing any persistent unreleased memory issues in your deployment, you can try passing this environment variables to `imaginary`: ``` MALLOC_ARENA_MAX=2 imaginary -p 9000 -enable-url-source @@ -330,8 +337,8 @@ Options: -key Define API key for authorization -mount Mount server local directory -http-cache-ttl The TTL in seconds. Adds caching headers to locally served files. - -http-read-timeout HTTP read timeout in seconds [default: 30] - -http-write-timeout HTTP write timeout in seconds [default: 30] + -http-read-timeout HTTP read timeout in seconds [default: 60] + -http-write-timeout HTTP write timeout in seconds [default: 60] -enable-url-source Enable remote HTTP URL image source processing (?url=http://..) -enable-placeholder Enable image response placeholder to be used in case of error [default: false] -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 @@ -340,6 +347,7 @@ Options: -url-signature-key The URL signature key (32 characters minimum) -allowed-origins Restrict remote image source processing to certain origins (separated by commas). Note: Origins are validated against host *AND* path. -max-allowed-size Restrict maximum size of http image source (in bytes) + -max-allowed-resolution Restrict maximum resolution of the image [default: 18.0] -certfile TLS certificate file path -keyfile TLS private key file path -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 @@ -422,7 +430,7 @@ This feature is particularly useful to protect against multiple image operations imaginary -p 8080 -enable-url-signature -url-signature-key 4f46feebafc4b5e988f131c4ff8b5997 ``` -It is recommanded to pass key as environment variables: +It is recommended to pass key as environment variables: ``` URL_SIGNATURE_KEY=4f46feebafc4b5e988f131c4ff8b5997 imaginary -p 8080 -enable-url-signature ``` @@ -477,13 +485,13 @@ imaginary can be configured to block all requests for images with a src URL this | `allowed-origins` setting | image url | is valid | | ------------------------- | --------- | -------- | -| `--allowed-origins s3.amazonaws.com/some-bucket/` | `s3.amazonaws.com/some-bucket/images/image.png` | VALID | -| `--allowed-origins s3.amazonaws.com/some-bucket/` | `s3.amazonaws.com/images/image.png` | NOT VALID (no matching basepath) | -| `--allowed-origins s3.amazonaws.com/some-*` | `s3.amazonaws.com/some-bucket/images/image.png` | VALID | -| `--allowed-origins *.amazonaws.com/some-bucket/` | `anysubdomain.amazonaws.com/some-bucket/images/image.png` | VALID | -| `--allowed-origins *.amazonaws.com` | `anysubdomain.amazonaws.comimages/image.png` | VALID | -| `--allowed-origins *.amazonaws.com` | `www.notaws.comimages/image.png` | NOT VALID (no matching host) | -| `--allowed-origins *.amazonaws.com, foo.amazonaws.com/some-bucket/` | `bar.amazonaws.com/some-other-bucket/image.png` | VALID (matches first condition but not second) | +| `-allowed-origins https://s3.amazonaws.com/some-bucket/` | `s3.amazonaws.com/some-bucket/images/image.png` | VALID | +| `-allowed-origins https://s3.amazonaws.com/some-bucket/` | `s3.amazonaws.com/images/image.png` | NOT VALID (no matching basepath) | +| `-allowed-origins https://s3.amazonaws.com/some-*` | `s3.amazonaws.com/some-bucket/images/image.png` | VALID | +| `-allowed-origins https://*.amazonaws.com/some-bucket/` | `anysubdomain.amazonaws.com/some-bucket/images/image.png` | VALID | +| `-allowed-origins https://*.amazonaws.com` | `anysubdomain.amazonaws.comimages/image.png` | VALID | +| `-allowed-origins https://*.amazonaws.com` | `www.notaws.comimages/image.png` | NOT VALID (no matching host) | +| `-allowed-origins https://*.amazonaws.com, foo.amazonaws.com/some-bucket/` | `bar.amazonaws.com/some-other-bucket/image.png` | VALID (matches first condition but not second) | ### Authorization @@ -565,6 +573,7 @@ Image measures are always in pixels, unless otherwise indicated. - **areaheight** `int` - Width area to extract. Example: `300` - **quality** `int` - JPEG image quality between 1-100. Defaults to `80` - **compression** `int` - PNG compression level. Default: `6` +- **palette** `bool` - Enable 8-bit quantisation. Works with only PNG images. Default: `false` - **rotate** `int` - Image rotation angle. Must be multiple of `90`. Example: `180` - **factor** `int` - Zoom factor level. Example: `2` - **margin** `int` - Text area margin for watermark. Example: `50` @@ -751,6 +760,7 @@ Resize an image by width or height. Image aspect ratio is maintained - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /enlarge Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -780,6 +790,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - minampl `float` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` +- palette `bool` #### GET | POST /extract Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -813,6 +824,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /zoom Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -844,6 +856,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /thumbnail Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -873,6 +886,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /fit Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -905,6 +919,7 @@ The width and height specify a maximum bounding box for the image. - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /rotate Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -942,6 +957,7 @@ Returns a new image with the same size and format as the input image. - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /flip Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -970,6 +986,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /flop Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -998,6 +1015,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /convert Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -1025,6 +1043,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` #### GET | POST /pipeline Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -1148,6 +1167,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - minampl `float` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` +- palette `bool` #### GET | POST /watermarkimage Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -1178,6 +1198,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - minampl `float` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` +- palette `bool` #### GET | POST /blur Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` @@ -1206,6 +1227,7 @@ Accepts: `image/*, multipart/form-data`. Content-Type: `image/*` - field `string` - Only POST and `multipart/form` payloads - interlace `bool` - aspectratio `string` +- palette `bool` ## Logging diff --git a/app.json b/app.json deleted file mode 100644 index 356453b2..00000000 --- a/app.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "Imaginary", - "description": "Fast HTTP microservice for high-level image processing.", - "keywords": [ - "image", - "processing", - "go", - "microservice", - "api", - "libvips", - "vips" - ], - "repository": "https://github.com/h2non/imaginary", - "logo": "https://camo.githubusercontent.com/bcb3b2bd343c3c85aaf6094e51c6178bbe239a32/687474703a2f2f7331342e706f7374696d672e6f72672f3874683731613230312f696d6167696e6172795f776f726c642e6a7067", - "success_url": "/", - "image": "heroku/go", - "mount_dir": "src/github.com/h2non/imaginary", - "buildpacks": [ - { - "url": "https://github.com/h2non/heroku-buildpack-imaginary.git" - } - ] -} diff --git a/controllers.go b/controllers.go index ae1142ff..1de48b55 100644 --- a/controllers.go +++ b/controllers.go @@ -5,6 +5,7 @@ import ( "fmt" "mime" "net/http" + "path" "strconv" "strings" @@ -12,19 +13,21 @@ import ( "github.com/h2non/filetype" ) -func indexController(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - ErrorReply(r, w, ErrNotFound, ServerOptions{}) - return - } +func indexController(o ServerOptions) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path.Join(o.PathPrefix, "/") { + ErrorReply(r, w, ErrNotFound, ServerOptions{}) + return + } - body, _ := json.Marshal(Versions{ - Version, - bimg.Version, - bimg.VipsVersion, - }) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) + body, _ := json.Marshal(Versions{ + Version, + bimg.Version, + bimg.VipsVersion, + }) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } } func healthController(w http.ResponseWriter, r *http.Request) { @@ -117,6 +120,21 @@ func imageHandler(w http.ResponseWriter, r *http.Request, buf []byte, operation return } + sizeInfo, err := bimg.Size(buf) + + if err != nil { + ErrorReply(r, w, NewError("Error while processing the image: "+err.Error(), http.StatusBadRequest), o) + return + } + + // https://en.wikipedia.org/wiki/Image_resolution#Pixel_count + imgResolution := float64(sizeInfo.Width) * float64(sizeInfo.Height) + + if (imgResolution / 1000000) > o.MaxAllowedPixels { + ErrorReply(r, w, ErrResolutionTooBig, o) + return + } + image, err := operation.Run(buf, opts) if err != nil { // Ensure the Vary header is set when an error occurs @@ -130,51 +148,60 @@ func imageHandler(w http.ResponseWriter, r *http.Request, buf []byte, operation // Expose Content-Length response header w.Header().Set("Content-Length", strconv.Itoa(len(image.Body))) w.Header().Set("Content-Type", image.Mime) + if image.Mime != "application/json" && o.ReturnSize { + meta, err := bimg.Metadata(image.Body) + if err == nil { + w.Header().Set("Image-Width", strconv.Itoa(meta.Size.Width)) + w.Header().Set("Image-Height", strconv.Itoa(meta.Size.Height)) + } + } if vary != "" { w.Header().Set("Vary", vary) } _, _ = w.Write(image.Body) } -func formController(w http.ResponseWriter, r *http.Request) { - operations := []struct { - name string - method string - args string - }{ - {"Resize", "resize", "width=300&height=200&type=jpeg"}, - {"Force resize", "resize", "width=300&height=200&force=true"}, - {"Crop", "crop", "width=300&quality=95"}, - {"SmartCrop", "crop", "width=300&height=260&quality=95&gravity=smart"}, - {"Extract", "extract", "top=100&left=100&areawidth=300&areaheight=150"}, - {"Enlarge", "enlarge", "width=1440&height=900&quality=95"}, - {"Rotate", "rotate", "rotate=180"}, - {"AutoRotate", "autorotate", "quality=90"}, - {"Flip", "flip", ""}, - {"Flop", "flop", ""}, - {"Thumbnail", "thumbnail", "width=100"}, - {"Zoom", "zoom", "factor=2&areawidth=300&top=80&left=80"}, - {"Color space (black&white)", "resize", "width=400&height=300&colorspace=bw"}, - {"Add watermark", "watermark", "textwidth=100&text=Hello&font=sans%2012&opacity=0.5&color=255,200,50"}, - {"Convert format", "convert", "type=png"}, - {"Image metadata", "info", ""}, - {"Gaussian blur", "blur", "sigma=15.0&minampl=0.2"}, - {"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"}, - } +func formController(o ServerOptions) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + operations := []struct { + name string + method string + args string + }{ + {"Resize", "resize", "width=300&height=200&type=jpeg"}, + {"Force resize", "resize", "width=300&height=200&force=true"}, + {"Crop", "crop", "width=300&quality=95"}, + {"SmartCrop", "crop", "width=300&height=260&quality=95&gravity=smart"}, + {"Extract", "extract", "top=100&left=100&areawidth=300&areaheight=150"}, + {"Enlarge", "enlarge", "width=1440&height=900&quality=95"}, + {"Rotate", "rotate", "rotate=180"}, + {"AutoRotate", "autorotate", "quality=90"}, + {"Flip", "flip", ""}, + {"Flop", "flop", ""}, + {"Thumbnail", "thumbnail", "width=100"}, + {"Zoom", "zoom", "factor=2&areawidth=300&top=80&left=80"}, + {"Color space (black&white)", "resize", "width=400&height=300&colorspace=bw"}, + {"Add watermark", "watermark", "textwidth=100&text=Hello&font=sans%2012&opacity=0.5&color=255,200,50"}, + {"Convert format", "convert", "type=png"}, + {"Image metadata", "info", ""}, + {"Gaussian blur", "blur", "sigma=15.0&minampl=0.2"}, + {"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"}, + } - html := "" + html := "" - for _, form := range operations { - html += fmt.Sprintf(` -

%s

-
- - -
`, form.name, form.method, form.args) - } + for _, form := range operations { + html += fmt.Sprintf(` +

%s

+
+ + +
`, path.Join(o.PathPrefix, form.name), path.Join(o.PathPrefix, form.method), form.args) + } - html += "" + html += "" - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write([]byte(html)) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(html)) + } } diff --git a/error.go b/error.go index d866e544..8d01660a 100644 --- a/error.go +++ b/error.go @@ -19,11 +19,12 @@ var ( ErrEmptyBody = NewError("Empty or unreadable image", http.StatusBadRequest) ErrMissingParamFile = NewError("Missing required param: file", http.StatusBadRequest) ErrInvalidFilePath = NewError("Invalid file path", http.StatusBadRequest) - ErrInvalidImageURL = NewError("Unvalid image URL", http.StatusBadRequest) + ErrInvalidImageURL = NewError("Invalid image URL", http.StatusBadRequest) ErrMissingImageSource = NewError("Cannot process the image due to missing or invalid params", http.StatusBadRequest) ErrNotImplemented = NewError("Not implemented endpoint", http.StatusNotImplemented) ErrInvalidURLSignature = NewError("Invalid URL signature", http.StatusBadRequest) ErrURLSignatureMismatch = NewError("URL signature mismatch", http.StatusForbidden) + ErrResolutionTooBig = NewError("Image resolution is too big", http.StatusUnprocessableEntity) ) type Error struct { diff --git a/go.mod b/go.mod index de5b10e9..4edda3dd 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,10 @@ go 1.12 require ( github.com/garyburd/redigo v1.6.0 // indirect + github.com/h2non/bimg v1.1.7 + github.com/h2non/filetype v1.1.0 github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad // indirect github.com/rs/cors v0.0.0-20170727213201-7af7a1e09ba3 - github.com/h2non/bimg v1.1.4 - github.com/h2non/filetype v1.1.0 gopkg.in/throttled/throttled.v2 v2.0.3 github.com/namsral/flag v1.7.4-pre ) diff --git a/go.sum b/go.sum index c54e640f..65992b86 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,7 @@ +github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc= github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= -github.com/h2non/bimg v1.1.2 h1:J75W2eM5FT0KjcwsL2aiy1Ilu0Xy0ENb0sU+HHUJAvw= -github.com/h2non/bimg v1.1.2/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= -github.com/h2non/bimg v1.1.4 h1:6qf7qDo3d9axbNUOcSoQmzleBCMTcQ1PwF3FgGhX4O0= -github.com/h2non/bimg v1.1.4/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= +github.com/h2non/bimg v1.1.7 h1:JKJe70nDNMWp2wFnTLMGB8qJWQQMaKRn56uHmC/4+34= +github.com/h2non/bimg v1.1.7/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA= github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/golang-lru v0.0.0-20160813221303-0a025b7e63ad h1:eMxs9EL0PvIGS9TTtxg4R+JxuPGav82J8rA+GFnY7po= diff --git a/image.go b/image.go index fa7a2778..1d637d7a 100644 --- a/image.go +++ b/image.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" "io" - "strings" "io/ioutil" "math" "net/http" + "strings" "github.com/h2non/bimg" ) diff --git a/imaginary.go b/imaginary.go index 71166559..624c8722 100644 --- a/imaginary.go +++ b/imaginary.go @@ -33,6 +33,7 @@ var ( aURLSignatureKey = flag.String("url-signature-key", "", "The URL signature key (32 characters minimum)") aAllowedOrigins = flag.String("allowed-origins", "", "Restrict remote image source processing to certain origins (separated by commas). Note: Origins are validated against host *AND* path.") aMaxAllowedSize = flag.Int("max-allowed-size", 0, "Restrict maximum size of http image source (in bytes)") + aMaxAllowedPixels = flag.Float64("max-allowed-resolution", 18.0, "Restrict maximum resolution of the image (in megapixels)") aKey = flag.String("key", "", "Define API key for authorization") aMount = flag.String("mount", "", "Mount server local directory") aCertFile = flag.String("certfile", "", "TLS certificate file path") @@ -50,6 +51,7 @@ var ( aMRelease = flag.Int("mrelease", 30, "OS memory release interval in seconds") aCpus = flag.Int("cpus", runtime.GOMAXPROCS(-1), "Number of cpu cores to use") aLogLevel = flag.String("log-level", "info", "Define log level for http-server. E.g: info,warning,error") + aReturnSize = flag.Bool("return-size", false, "Return the image size in the HTTP headers") ) const usage = `imaginary %s @@ -94,6 +96,7 @@ Options: -url-signature-key The URL signature key (32 characters minimum) -allowed-origins Restrict remote image source processing to certain origins (separated by commas) -max-allowed-size Restrict maximum size of http image source (in bytes) + -max-allowed-resolution Restrict maximum resolution of the image [default: 18.0] -certfile TLS certificate file path -keyfile TLS private key file path -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 @@ -106,6 +109,7 @@ Options: (default for current machine is %d cores) -log-level Set log level for http-server. E.g: info,warning,error [default: info]. Or can use the environment variable GOLANG_LOG=info. + -return-size Return the image size with X-Width and X-Height HTTP header. [default: disabled]. ` type URLSignature struct { @@ -114,7 +118,7 @@ type URLSignature struct { func main() { flag.Usage = func() { - _, _ = fmt.Fprint(os.Stderr, usage, Version, runtime.NumCPU()) + _, _ = fmt.Fprintf(os.Stderr, usage, Version, runtime.NumCPU()) } flag.Parse() @@ -156,7 +160,9 @@ func main() { ForwardHeaders: parseForwardHeaders(*aForwardHeaders), AllowedOrigins: parseOrigins(*aAllowedOrigins), MaxAllowedSize: *aMaxAllowedSize, + MaxAllowedPixels: *aMaxAllowedPixels, LogLevel: getLogLevel(*aLogLevel), + ReturnSize: *aReturnSize, } // Show warning if gzip flag is passed diff --git a/log.go b/log.go index e1f87321..dabf65d1 100644 --- a/log.go +++ b/log.go @@ -44,9 +44,9 @@ func (r *LogRecord) WriteHeader(status int) { // LogHandler maps the HTTP handler with a custom io.Writer compatible stream type LogHandler struct { - handler http.Handler - io io.Writer - logLevel string + handler http.Handler + io io.Writer + logLevel string } // NewLog creates a new logger @@ -79,7 +79,7 @@ func (h *LogHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { record.time = finishTime.UTC() record.elapsedTime = finishTime.Sub(startTime) - switch h.logLevel{ + switch h.logLevel { case "error": if record.status >= http.StatusInternalServerError { record.Log(h.io) diff --git a/options.go b/options.go index c2927741..bc639c59 100644 --- a/options.go +++ b/options.go @@ -44,6 +44,7 @@ type ImageOptions struct { Color []uint8 Background []uint8 Interlace bool + Speed int Extend bimg.Extend Gravity bimg.Gravity Colorspace bimg.Interpretation @@ -63,6 +64,7 @@ type IsDefinedField struct { NoProfile bool StripMetadata bool Interlace bool + Palette bool } // PipelineOperation represents the structure for an operation field. @@ -142,6 +144,8 @@ func BimgOptions(o ImageOptions) bimg.Options { Type: ImageType(o.Type), Rotate: bimg.Angle(o.Rotate), Interlace: o.Interlace, + Palette: o.Palette, + Speed: o.Speed, } if len(o.Background) != 0 { diff --git a/params.go b/params.go index dc3db695..8d133b7b 100644 --- a/params.go +++ b/params.go @@ -55,6 +55,8 @@ var paramTypeCoercions = map[string]Coercion{ "operations": coerceOperations, "interlace": coerceInterlace, "aspectratio": coerceAspectRatio, + "palette": coercePalette, + "speed": coerceSpeed, } func coerceTypeInt(param interface{}) (int, error) { @@ -343,6 +345,17 @@ func coerceInterlace(io *ImageOptions, param interface{}) (err error) { return err } +func coercePalette(io *ImageOptions, param interface{}) (err error) { + io.Palette, err = coerceTypeBool(param) + io.IsDefinedField.Palette = true + return err +} + +func coerceSpeed(io *ImageOptions, param interface{}) (err error) { + io.Speed, err = coerceTypeInt(param) + return err +} + func buildParamsFromOperation(op PipelineOperation) (ImageOptions, error) { var options ImageOptions diff --git a/server.go b/server.go index c2196951..f3a17d73 100644 --- a/server.go +++ b/server.go @@ -2,15 +2,15 @@ package main import ( "context" + "log" "net/http" "net/url" - "log" "os" "os/signal" - "syscall" "path" "strconv" "strings" + "syscall" "time" ) @@ -22,6 +22,7 @@ type ServerOptions struct { HTTPReadTimeout int HTTPWriteTimeout int MaxAllowedSize int + MaxAllowedPixels float64 CORS bool Gzip bool // deprecated AuthForwarding bool @@ -43,6 +44,7 @@ type ServerOptions struct { Endpoints Endpoints AllowedOrigins []*url.URL LogLevel string + ReturnSize bool } // Endpoints represents a list of endpoint names to disable. @@ -110,8 +112,8 @@ func join(o ServerOptions, route string) string { func NewServerMux(o ServerOptions) http.Handler { mux := http.NewServeMux() - mux.Handle(join(o, "/"), Middleware(indexController, o)) - mux.Handle(join(o, "/form"), Middleware(formController, o)) + mux.Handle(join(o, "/"), Middleware(indexController(o), o)) + mux.Handle(join(o, "/form"), Middleware(formController(o), o)) mux.Handle(join(o, "/health"), Middleware(healthController, o)) image := ImageMiddleware(o) diff --git a/server_test.go b/server_test.go index 90ebc812..adcbf5a0 100644 --- a/server_test.go +++ b/server_test.go @@ -16,7 +16,8 @@ import ( ) func TestIndex(t *testing.T) { - ts := testServer(indexController) + opts := ServerOptions{PathPrefix: "/", MaxAllowedPixels: 18.0} + ts := testServer(indexController(opts)) defer ts.Close() res, err := http.Get(ts.URL) @@ -274,7 +275,7 @@ func TestFit(t *testing.T) { } func TestRemoteHTTPSource(t *testing.T) { - opts := ServerOptions{EnableURLSource: true} + opts := ServerOptions{EnableURLSource: true, MaxAllowedPixels: 18.0} fn := ImageMiddleware(opts)(Crop) LoadSources(opts) @@ -315,7 +316,7 @@ func TestRemoteHTTPSource(t *testing.T) { } func TestInvalidRemoteHTTPSource(t *testing.T) { - opts := ServerOptions{EnableURLSource: true} + opts := ServerOptions{EnableURLSource: true, MaxAllowedPixels: 18.0} fn := ImageMiddleware(opts)(Crop) LoadSources(opts) @@ -338,7 +339,7 @@ func TestInvalidRemoteHTTPSource(t *testing.T) { } func TestMountDirectory(t *testing.T) { - opts := ServerOptions{Mount: "testdata"} + opts := ServerOptions{Mount: "testdata", MaxAllowedPixels: 18.0} fn := ImageMiddleware(opts)(Crop) LoadSources(opts) @@ -373,7 +374,7 @@ func TestMountDirectory(t *testing.T) { } func TestMountInvalidDirectory(t *testing.T) { - fn := ImageMiddleware(ServerOptions{Mount: "_invalid_"})(Crop) + fn := ImageMiddleware(ServerOptions{Mount: "_invalid_", MaxAllowedPixels: 18.0})(Crop) ts := httptest.NewServer(fn) url := ts.URL + "?top=100&left=100&areawidth=200&areaheight=120&file=large.jpg" defer ts.Close() @@ -407,7 +408,7 @@ func TestMountInvalidPath(t *testing.T) { func controller(op Operation) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { buf, _ := ioutil.ReadAll(r.Body) - imageHandler(w, r, buf, op, ServerOptions{}) + imageHandler(w, r, buf, op, ServerOptions{MaxAllowedPixels: 18.0}) } } diff --git a/source_http_test.go b/source_http_test.go index 90d4816e..aa542726 100755 --- a/source_http_test.go +++ b/source_http_test.go @@ -119,7 +119,7 @@ func TestHttpImageSourceForwardAuthHeader(t *testing.T) { source.setAuthorizationHeader(oreq, r) if oreq.Header.Get("Authorization") != "foobar" { - t.Fatal("Missmatch Authorization header") + t.Fatal("Mismatch Authorization header") } } } @@ -143,7 +143,7 @@ func TestHttpImageSourceForwardHeaders(t *testing.T) { source.setForwardHeaders(oreq, r) if oreq.Header.Get(header) != "foobar" { - t.Fatal("Missmatch custom header") + t.Fatal("Mismatch custom header") } } } @@ -231,7 +231,7 @@ func TestHttpImageSourceEmptyForwardedHeaders(t *testing.T) { if len(source.Config.ForwardHeaders) != 0 { t.Log(source.Config.ForwardHeaders) - t.Fatal("Setted empty custom header") + t.Fatal("Set empty custom header") } oreq := newHTTPRequest(source, r, http.MethodGet, testURL)