├── test ├── test_helper.exs ├── support │ ├── image.gif │ ├── image.jpg │ └── image.svg └── plug_image_processing │ ├── image_metadata_test.exs │ ├── operations │ ├── echo_test.exs │ ├── info_test.exs │ ├── smartcrop_test.exs │ ├── extract_area_test.exs │ ├── flip_test.exs │ ├── pipeline_test.exs │ ├── resize_test.exs │ ├── watermark_image_test.exs │ └── crop_test.exs │ ├── middlewares │ ├── cache_headers_test.exs │ ├── allowed_origins_test.exs │ └── signature_key_test.exs │ ├── config_test.exs │ ├── plug_image_processing_test.exs │ ├── options_test.exs │ ├── sources │ └── url_test.exs │ └── web_test.exs ├── .tool-versions ├── .formatter.exs ├── lib ├── plug_image_processing │ ├── info.ex │ ├── sources │ │ ├── http_client │ │ │ ├── http_client.ex │ │ │ └── hackney.ex │ │ ├── http_client_cache │ │ │ ├── default.ex │ │ │ └── http_client_cache.ex │ │ └── url.ex │ ├── operation.ex │ ├── image_metadata.ex │ ├── source.ex │ ├── operations │ │ ├── echo.ex │ │ ├── smartcrop.ex │ │ ├── info.ex │ │ ├── extract_area.ex │ │ ├── flip.ex │ │ ├── resize.ex │ │ ├── pipeline.ex │ │ ├── crop.ex │ │ └── watermark_image.ex │ ├── middleware.ex │ ├── middlewares │ │ ├── signature_key.ex │ │ ├── cache_headers.ex │ │ └── allowed_origins.ex │ ├── options.ex │ ├── config.ex │ └── web.ex └── plug_image_processing.ex ├── .gitignore ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── LICENSE.md ├── Makefile ├── mix.exs ├── .credo.exs ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.2 2 | elixir 1.18.1-otp-27 3 | -------------------------------------------------------------------------------- /test/support/image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirego/plug_image_processing/HEAD/test/support/image.gif -------------------------------------------------------------------------------- /test/support/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mirego/plug_image_processing/HEAD/test/support/image.jpg -------------------------------------------------------------------------------- /test/support/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "mix.exs", 4 | ".formatter.exs", 5 | ".credo.exs", 6 | "{config,lib,test,rel}/**/*.{ex,exs}" 7 | ], 8 | plugins: [Styler], 9 | line_length: 180 10 | ] 11 | -------------------------------------------------------------------------------- /lib/plug_image_processing/info.ex: -------------------------------------------------------------------------------- 1 | defprotocol PlugImageProcessing.Info do 2 | @typep error :: {:error, atom()} 3 | @type t :: struct() 4 | 5 | @spec process(t()) :: {:ok, PlugImageProcessing.ImageMetadata.t()} | error() 6 | def process(operation) 7 | end 8 | -------------------------------------------------------------------------------- /lib/plug_image_processing/sources/http_client/http_client.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Sources.HTTPClient do 2 | @moduledoc false 3 | @callback get(url :: String.t(), max_length :: non_neg_integer()) :: {:ok, binary(), Keyword.t()} | {:http_error, any()} | {:error, any()} 4 | end 5 | -------------------------------------------------------------------------------- /lib/plug_image_processing/sources/http_client_cache/default.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Sources.HTTPClientCache.Default do 2 | @moduledoc false 3 | @behaviour PlugImageProcessing.Sources.HTTPClientCache 4 | 5 | def invalid_source?(_source), do: false 6 | def fetch_source(_source), do: nil 7 | def put_source(_source, _), do: :ok 8 | end 9 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operation.ex: -------------------------------------------------------------------------------- 1 | defprotocol PlugImageProcessing.Operation do 2 | @typep error :: {:error, atom()} 3 | @type t :: struct() 4 | 5 | @spec valid?(t()) :: boolean() | error() 6 | def valid?(operation) 7 | 8 | @spec process(t(), PlugImageProcessing.Config.t()) :: {:ok, PlugImageProcessing.image()} | error() 9 | def process(operation, config) 10 | end 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | /cover 7 | /tmp 8 | /doc 9 | 10 | # Generated on crash by the VM 11 | erl_crash.dump 12 | 13 | # Since we are building assets from assets/, we ignore priv/static 14 | /priv/static 15 | 16 | # Local environment variable files 17 | .env.local 18 | .env.*.local 19 | 20 | # Sobelow version breadcrumb 21 | .sobelow 22 | -------------------------------------------------------------------------------- /lib/plug_image_processing/sources/http_client_cache/http_client_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Sources.HTTPClientCache do 2 | @moduledoc false 3 | @typep source :: PlugImageProcessing.Sources.URL.t() 4 | @callback invalid_source?(source()) :: boolean() 5 | @callback fetch_source(source()) :: nil | {:ok, binary(), Keyword.t()} 6 | @callback put_source(source(), any()) :: :ok 7 | end 8 | -------------------------------------------------------------------------------- /lib/plug_image_processing/image_metadata.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.ImageMetadata do 2 | @moduledoc false 3 | @derive Jason.Encoder 4 | defstruct channels: nil, has_alpha: nil, height: nil, width: nil 5 | 6 | @type t :: %__MODULE__{ 7 | channels: number(), 8 | has_alpha: boolean(), 9 | height: number(), 10 | width: number() 11 | } 12 | end 13 | -------------------------------------------------------------------------------- /lib/plug_image_processing/source.ex: -------------------------------------------------------------------------------- 1 | defprotocol PlugImageProcessing.Source do 2 | @spec get_image(struct(), String.t(), PlugImageProcessing.Config.t()) :: 3 | {:ok, PlugImageProcessing.image(), String.t() | nil, String.t()} | {:error, atom()} | {:redirect, String.t()} 4 | def get_image(source, operation_name, config) 5 | 6 | @spec cast(struct(), map()) :: struct() | boolean() 7 | def cast(source, params) 8 | end 9 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/echo.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Echo do 2 | @moduledoc false 3 | defstruct image: nil 4 | 5 | def new(image, _params, _config) do 6 | {:ok, struct!(__MODULE__, %{image: image})} 7 | end 8 | 9 | defimpl PlugImageProcessing.Operation do 10 | def valid?(_operation) do 11 | true 12 | end 13 | 14 | def process(operation, _config) do 15 | {:ok, operation.image} 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/smartcrop.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Smartcrop do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | def new(image, params, _config) do 6 | with {:ok, width} <- cast_integer(params["width"]), 7 | {:ok, height} <- cast_integer(params["height"]) do 8 | {:ok, 9 | struct!(PlugImageProcessing.Operations.Crop, %{ 10 | image: image, 11 | gravity: "smart", 12 | width: width, 13 | height: height 14 | })} 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | env: 12 | HEX_API_KEY: ${{ secrets.MIREGO_HEXPM_API_KEY }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: 25.x 18 | elixir-version: 1.14.x 19 | - run: sudo apt-get update 20 | - run: sudo apt-get install -y libvips-dev 21 | - run: vips --vips-version 22 | - run: make prepare 23 | - run: mix compile --docs 24 | - run: mix hex.publish --yes 25 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/info.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Info do 2 | @moduledoc false 3 | alias Vix.Vips.Image 4 | 5 | defstruct image: nil 6 | 7 | def new(_image, _params, _config) do 8 | {:error, :invalid_operation} 9 | end 10 | 11 | defimpl PlugImageProcessing.Info do 12 | def process(operation) do 13 | {:ok, 14 | %PlugImageProcessing.ImageMetadata{ 15 | channels: Image.bands(operation.image), 16 | has_alpha: Image.has_alpha?(operation.image), 17 | height: Image.height(operation.image), 18 | width: Image.width(operation.image) 19 | }} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/plug_image_processing/sources/http_client/hackney.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Sources.HTTPClient.Hackney do 2 | @moduledoc false 3 | @behaviour PlugImageProcessing.Sources.HTTPClient 4 | 5 | def get(url, max_length) do 6 | with {:ok, 200, headers, client_reference} <- :hackney.get(url, [], <<>>, follow_redirect: true), 7 | {:ok, body} when is_binary(body) <- :hackney.body(client_reference, max_length) do 8 | {:ok, body, headers} 9 | else 10 | {:ok, status, _, _} -> 11 | {:http_error, status} 12 | 13 | {:error, error} -> 14 | {:error, error} 15 | 16 | error -> 17 | {:error, error} 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/extract_area.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.ExtractArea do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | def new(image, params, _config) do 6 | with {:ok, width} <- cast_integer(params["width"]), 7 | {:ok, left} <- cast_integer(params["left"], 0), 8 | {:ok, top} <- cast_integer(params["top"], 0), 9 | {:ok, height} <- cast_integer(params["height"]) do 10 | {:ok, 11 | struct!(PlugImageProcessing.Operations.Crop, %{ 12 | image: image, 13 | top: top, 14 | left: left, 15 | width: width, 16 | height: height 17 | })} 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/flip.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Flip do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | defstruct image: nil, direction: nil 6 | 7 | def new(image, params, _config) do 8 | with {:ok, direction} <- cast_direction(params["flip"], :VIPS_DIRECTION_HORIZONTAL), 9 | {:ok, direction} <- cast_direction(params["direction"], direction), 10 | {:ok, direction} <- cast_boolean(params["flip"], direction) do 11 | {:ok, 12 | struct!(__MODULE__, %{ 13 | image: image, 14 | direction: direction 15 | })} 16 | end 17 | end 18 | 19 | defimpl PlugImageProcessing.Operation do 20 | def valid?(_operation) do 21 | true 22 | end 23 | 24 | def process(operation, _config) do 25 | direction = if is_boolean(operation.direction), do: :VIPS_DIRECTION_HORIZONTAL, else: operation.direction 26 | 27 | Vix.Vips.Operation.flip(operation.image, direction) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/resize.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Resize do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | alias Vix.Vips.Image 6 | 7 | defstruct image: nil, width: nil, height: nil 8 | 9 | def new(image, params, _config) do 10 | with {:ok, width} <- cast_integer(params["w"] || params["width"]), 11 | {:ok, height} <- cast_integer(params["h"] || params["height"]) do 12 | {:ok, 13 | struct!(__MODULE__, %{ 14 | image: image, 15 | width: width, 16 | height: height 17 | })} 18 | end 19 | end 20 | 21 | defimpl PlugImageProcessing.Operation do 22 | def valid?(operation) do 23 | if operation.width do 24 | true 25 | else 26 | {:error, :missing_width} 27 | end 28 | end 29 | 30 | def process(operation, _config) do 31 | hscale = operation.width / Image.width(operation.image) * 1.0 32 | vscale = if operation.height, do: operation.height / Image.height(operation.image) 33 | 34 | options = PlugImageProcessing.Options.build(vscale: vscale) 35 | 36 | Vix.Vips.Operation.resize(operation.image, hscale, options) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | otp-version: [27.0] 11 | elixir-version: [1.17.1] 12 | 13 | env: 14 | MIX_ENV: test 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: erlef/setup-beam@v1 20 | with: 21 | elixir-version: ${{ matrix.elixir-version }} 22 | otp-version: ${{ matrix.otp-version }} 23 | 24 | - uses: actions/cache@v4 25 | id: deps-cache 26 | with: 27 | path: deps 28 | key: ${{ runner.os }}-deps-${{ hashFiles(format('{0}/mix.lock', github.workspace)) }} 29 | restore-keys: | 30 | ${{ runner.os }}-deps- 31 | 32 | - uses: actions/cache@v4 33 | id: build-cache 34 | with: 35 | path: _build 36 | key: ${{ runner.os }}-build-${{ matrix.otp-version }}-${{ matrix.elixir-version }}-${{ hashFiles(format('{0}/mix.lock', github.workspace)) }} 37 | 38 | - run: sudo apt-get update 39 | - run: sudo apt-get install -y libvips-dev 40 | - run: vips --vips-version 41 | - run: make prepare 42 | - run: make lint 43 | - run: make check-github 44 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Pipeline do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | defstruct image: nil, operations: nil 6 | 7 | def new(image, params, _config) do 8 | with {:ok, operations} <- cast_json(params["operations"]) do 9 | {:ok, 10 | struct!(__MODULE__, %{ 11 | image: image, 12 | operations: operations 13 | })} 14 | end 15 | end 16 | 17 | defimpl PlugImageProcessing.Operation do 18 | def valid?(operation) do 19 | if Enum.any?(operation.operations) do 20 | true 21 | else 22 | {:error, :invalid_operations} 23 | end 24 | end 25 | 26 | def process(operation, config) do 27 | image = 28 | Enum.reduce_while(operation.operations, operation.image, fn operation, image -> 29 | operation_name = operation["operation"] 30 | params = operation["params"] 31 | 32 | case PlugImageProcessing.operations(image, operation_name, params, config) do 33 | {:ok, image} -> {:cont, image} 34 | error -> {:halt, error} 35 | end 36 | end) 37 | 38 | case image do 39 | %Vix.Vips.Image{} = image -> {:ok, image} 40 | error -> error 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/plug_image_processing/middleware.ex: -------------------------------------------------------------------------------- 1 | defprotocol PlugImageProcessing.Middleware do 2 | @moduledoc """ 3 | Protocol for implementing middleware in the image processing pipeline. 4 | 5 | Middleware can be used to add security checks, modify headers, validate 6 | requests, or perform any other processing on the connection before or 7 | after image operations. 8 | """ 9 | 10 | @type t :: struct() 11 | 12 | @doc """ 13 | Executes the middleware logic on the connection. 14 | 15 | This function should process the connection and return a potentially 16 | modified connection. It may halt the connection if validation fails. 17 | 18 | ## Parameters 19 | - `middleware` - The middleware struct containing configuration 20 | - `conn` - The Plug.Conn struct to process 21 | 22 | ## Returns 23 | - Modified Plug.Conn struct (may be halted) 24 | """ 25 | @spec run(t(), Plug.Conn.t()) :: Plug.Conn.t() 26 | def run(middleware, conn) 27 | 28 | @doc """ 29 | Determines if this middleware should be enabled for the given connection. 30 | 31 | This allows conditional activation of middleware based on configuration 32 | or request parameters. 33 | 34 | ## Parameters 35 | - `middleware` - The middleware struct containing configuration 36 | - `conn` - The Plug.Conn struct to check (may be nil during initialization) 37 | 38 | ## Returns 39 | - `true` if the middleware should run, `false` otherwise 40 | """ 41 | @spec enabled?(t(), Plug.Conn.t() | nil) :: boolean() 42 | def enabled?(middleware, conn) 43 | end 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Mirego 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | - Neither the name of the Mirego nor the names of its contributors may 13 | be used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /lib/plug_image_processing/middlewares/signature_key.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Middlewares.SignatureKey do 2 | @moduledoc false 3 | defstruct config: nil 4 | 5 | def generate_signature(url, config) do 6 | uri = URI.parse(url) 7 | url_path = uri.path 8 | url_path = String.trim_leading(url_path, config.path <> "/") 9 | 10 | url_query = 11 | uri.query 12 | |> URI.decode_query() 13 | |> Enum.sort_by(fn {key, _} -> key end) 14 | |> Map.new() 15 | |> Map.delete("sign") 16 | |> URI.encode_query() 17 | 18 | Base.url_encode64(:crypto.mac(:hmac, :sha256, config.url_signature_key, url_path <> url_query)) 19 | end 20 | 21 | defimpl PlugImageProcessing.Middleware do 22 | import Plug.Conn 23 | 24 | require Logger 25 | 26 | def enabled?(middleware, _conn), do: is_binary(middleware.config.url_signature_key) 27 | 28 | def run(middleware, conn) do 29 | valid_sign = 30 | PlugImageProcessing.Middlewares.SignatureKey.generate_signature( 31 | conn.request_path <> "?" <> conn.query_string, 32 | middleware.config 33 | ) 34 | 35 | provided_sign = conn.params["sign"] || "" 36 | 37 | if Plug.Crypto.secure_compare(valid_sign, provided_sign) do 38 | conn 39 | else 40 | Logger.error("[PlugImageProcessing] - Invalid signature. Got: #{inspect(conn.params["sign"])}") 41 | 42 | conn 43 | |> send_resp(:unauthorized, "Unauthorized: Invalid signature") 44 | |> halt() 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/plug_image_processing/middlewares/cache_headers.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Middlewares.CacheHeaders do 2 | @moduledoc """ 3 | Middleware for setting HTTP cache control headers on image responses. 4 | 5 | This middleware manages client and CDN caching behavior for processed images, 6 | helping to reduce server load and improve performance by leveraging browser 7 | and CDN caches. 8 | 9 | ## Configuration 10 | 11 | Set `http_cache_ttl` in your config to enable this middleware: 12 | 13 | # Cache images for 1 hour (3600 seconds) 14 | plug PlugImageProcessing.Web, http_cache_ttl: 3600 15 | 16 | # Disable caching 17 | plug PlugImageProcessing.Web, http_cache_ttl: 0 18 | 19 | ## Cache Behavior 20 | 21 | - TTL > 0: Sets public caching with the specified max-age 22 | - TTL = 0: Disables caching with no-cache, no-store directives 23 | - Includes s-maxage for shared cache (CDN) control 24 | - Sets no-transform to prevent CDN image modifications 25 | """ 26 | defstruct config: nil 27 | 28 | defimpl PlugImageProcessing.Middleware do 29 | import Plug.Conn 30 | 31 | def enabled?(middleware, _conn) do 32 | is_integer(middleware.config.http_cache_ttl) 33 | end 34 | 35 | def run(middleware, conn) do 36 | ttl = middleware.config.http_cache_ttl 37 | 38 | put_resp_header(conn, "cache-control", cache_control(ttl)) 39 | end 40 | 41 | defp cache_control(0), do: "private, no-cache, no-store, must-revalidate" 42 | 43 | defp cache_control(ttl) do 44 | "public, s-maxage=#{ttl}, max-age=#{ttl}, no-transform" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/crop.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.Crop do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | alias Vix.Vips.Operation 6 | 7 | defstruct image: nil, left: 0, top: 0, width: nil, height: nil, gravity: nil 8 | 9 | def new(image, params, _config) do 10 | with {:ok, width} <- cast_integer(params["width"]), 11 | {:ok, left} <- cast_integer(params["left"], 0), 12 | {:ok, top} <- cast_integer(params["top"], 0), 13 | {:ok, height} <- cast_integer(params["height"]) do 14 | {:ok, 15 | struct!(__MODULE__, %{ 16 | image: image, 17 | gravity: params["gravity"], 18 | top: top, 19 | left: left, 20 | width: width, 21 | height: height 22 | })} 23 | end 24 | end 25 | 26 | defimpl PlugImageProcessing.Operation do 27 | def valid?(operation) do 28 | if operation.width && operation.height && operation.top && operation.left do 29 | true 30 | else 31 | {:error, :missing_arguments} 32 | end 33 | end 34 | 35 | def process(%{gravity: "smart"} = operation, _config) do 36 | case Operation.smartcrop(operation.image, operation.width, operation.height) do 37 | {:ok, {cropped_image, _}} when is_struct(cropped_image, Vix.Vips.Image) -> 38 | {:ok, cropped_image} 39 | 40 | error -> 41 | error 42 | end 43 | end 44 | 45 | def process(operation, _config) do 46 | Operation.extract_area( 47 | operation.image, 48 | operation.left, 49 | operation.top, 50 | operation.width, 51 | operation.height 52 | ) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/plug_image_processing/operations/watermark_image.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.WatermarkImage do 2 | @moduledoc false 3 | import PlugImageProcessing.Options 4 | 5 | defstruct image: nil, sub: nil, left: nil, top: nil, right: nil, bottom: nil, http_client: nil 6 | 7 | def new(image, params, config) do 8 | with {:ok, sub} <- cast_remote_image(params["image"], "watermarkimage", config), 9 | {:ok, left} <- cast_integer(params["left"]), 10 | {:ok, right} <- cast_integer(params["right"]), 11 | {:ok, bottom} <- cast_integer(params["bottom"]), 12 | {:ok, top} <- cast_integer(params["top"]) do 13 | {:ok, 14 | struct!(__MODULE__, %{ 15 | image: image, 16 | sub: sub, 17 | left: left, 18 | right: right, 19 | top: top, 20 | bottom: bottom 21 | })} 22 | end 23 | end 24 | 25 | defimpl PlugImageProcessing.Operation do 26 | alias Vix.Vips.Image 27 | 28 | def valid?(operation) do 29 | if operation.sub do 30 | true 31 | else 32 | {:error, :missing_image} 33 | end 34 | end 35 | 36 | def process(operation, _config) do 37 | x = if operation.left, do: operation.left 38 | x = if operation.right, do: Image.width(operation.image) - Image.width(operation.sub) - operation.right, else: x 39 | x = x || 0 40 | 41 | y = if operation.top, do: operation.top 42 | y = if operation.bottom, do: Image.height(operation.image) - Image.height(operation.sub) - operation.bottom, else: y 43 | y = y || 0 44 | 45 | Vix.Vips.Operation.composite([operation.image, operation.sub], [:VIPS_BLEND_MODE_OVER], x: [x], y: [y]) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/plug_image_processing/middlewares/allowed_origins.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Middlewares.AllowedOrigins do 2 | @moduledoc """ 3 | Middleware for validating that image URLs come from allowed origins. 4 | 5 | This middleware provides security by restricting which domains can be used 6 | as image sources. It validates the host of the provided URL against a 7 | configured allowlist of origins. 8 | 9 | ## Configuration 10 | 11 | Set `allowed_origins` in your config to enable this middleware: 12 | 13 | plug PlugImageProcessing.Web, allowed_origins: ["example.com", "cdn.example.com"] 14 | 15 | ## Security 16 | 17 | - Uses exact host matching to prevent subdomain bypass attacks 18 | - Safely handles malformed URLs without crashing 19 | - Returns 403 Forbidden for unauthorized origins 20 | """ 21 | defstruct config: nil 22 | 23 | defimpl PlugImageProcessing.Middleware do 24 | import Plug.Conn 25 | 26 | require Logger 27 | 28 | def enabled?(middleware, conn) do 29 | not is_nil(conn.params["url"]) and is_list(middleware.config.allowed_origins) 30 | end 31 | 32 | def run(middleware, conn) do 33 | origins = middleware.config.allowed_origins 34 | 35 | with url when not is_nil(url) <- conn.params["url"], 36 | {:ok, decoded_url} <- safe_decode_url(url), 37 | uri when not is_nil(uri.host) <- URI.parse(decoded_url), 38 | true <- Enum.member?(origins, uri.host) do 39 | conn 40 | else 41 | _ -> 42 | Logger.error("[PlugImageProcessing] - Unallowed origins. Expected one of: #{inspect(origins)}") 43 | 44 | conn 45 | |> send_resp(:forbidden, "Forbidden: Unallowed origin") 46 | |> halt() 47 | end 48 | end 49 | 50 | defp safe_decode_url(url) do 51 | {:ok, URI.decode_www_form(url)} 52 | rescue 53 | _ -> {:error, :invalid_url} 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/plug_image_processing/options.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Options do 2 | @moduledoc false 3 | alias PlugImageProcessing.Sources.URL 4 | 5 | def build(options) do 6 | options 7 | |> Enum.map(fn 8 | {key, {:ok, value}} -> {key, value} 9 | {key, value} -> {key, value} 10 | _ -> {nil, nil} 11 | end) 12 | |> Enum.reject(fn {_key, value} -> is_nil(value) end) 13 | end 14 | 15 | def encode_suffix(options) do 16 | options = Enum.map_join(options, ",", fn {key, value} -> "#{key}=#{value}" end) 17 | if options === "", do: options, else: "[#{options}]" 18 | end 19 | 20 | def cast_direction(value, default \\ nil) 21 | def cast_direction("x", _default), do: {:ok, :VIPS_DIRECTION_HORIZONTAL} 22 | def cast_direction("y", _default), do: {:ok, :VIPS_DIRECTION_VERTICAL} 23 | def cast_direction(_, default), do: {:ok, default} 24 | 25 | def cast_boolean(value, default \\ nil) 26 | def cast_boolean("true", _default), do: {:ok, true} 27 | def cast_boolean("false", _default), do: {:ok, false} 28 | def cast_boolean(_, default), do: {:ok, default} 29 | 30 | def cast_remote_image(url, operation_name, config) do 31 | with %URL{} = source <- PlugImageProcessing.Source.cast(%URL{}, %{"url" => url}), 32 | {:ok, image, _, _} <- PlugImageProcessing.Source.get_image(source, operation_name, config) do 33 | {:ok, image} 34 | end 35 | end 36 | 37 | def cast_integer(value, default \\ nil) 38 | 39 | def cast_integer(nil, default), do: {:ok, default} 40 | 41 | def cast_integer(value, _) when is_integer(value), do: {:ok, value} 42 | 43 | def cast_integer(value, _) do 44 | case Integer.parse(value) do 45 | {value, _} -> {:ok, value} 46 | _ -> {:error, :bad_request} 47 | end 48 | end 49 | 50 | def cast_json(nil), do: {:error, :bad_request} 51 | 52 | def cast_json(operations) do 53 | case Jason.decode(operations) do 54 | {:ok, operations} -> {:ok, operations} 55 | _ -> {:error, :bad_request} 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/plug_image_processing/config.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Config do 2 | @moduledoc false 3 | alias PlugImageProcessing.Middlewares 4 | alias PlugImageProcessing.Operations 5 | alias PlugImageProcessing.Sources 6 | 7 | @sources [ 8 | Sources.URL 9 | ] 10 | 11 | @operations [ 12 | {"", Operations.Echo}, 13 | {"crop", Operations.Crop}, 14 | {"flip", Operations.Flip}, 15 | {"watermarkimage", Operations.WatermarkImage}, 16 | {"extract", Operations.ExtractArea}, 17 | {"resize", Operations.Resize}, 18 | {"smartcrop", Operations.Smartcrop}, 19 | {"pipeline", Operations.Pipeline}, 20 | {"info", Operations.Info} 21 | ] 22 | 23 | @middlewares [ 24 | Middlewares.SignatureKey, 25 | Middlewares.AllowedOrigins, 26 | Middlewares.CacheHeaders 27 | ] 28 | 29 | @enforce_keys ~w(path)a 30 | defstruct path: nil, 31 | sources: @sources, 32 | operations: @operations, 33 | middlewares: @middlewares, 34 | onerror: %{}, 35 | url_signature_key: nil, 36 | allowed_origins: nil, 37 | source_url_redirect_operations: [], 38 | http_client_cache: PlugImageProcessing.Sources.HTTPClientCache.Default, 39 | http_client: PlugImageProcessing.Sources.HTTPClient.Hackney, 40 | http_client_timeout: 10_000, 41 | http_client_max_length: 1_000_000_000, 42 | http_cache_ttl: nil 43 | 44 | @type t :: %__MODULE__{ 45 | path: String.t() | nil, 46 | middlewares: list(module()), 47 | operations: list({String.t(), module()}), 48 | sources: list(module()), 49 | source_url_redirect_operations: list(String.t()), 50 | onerror: %{}, 51 | http_client: module(), 52 | url_signature_key: String.t() | nil, 53 | allowed_origins: list(String.t()) | nil, 54 | http_cache_ttl: non_neg_integer() | nil, 55 | http_client_timeout: non_neg_integer(), 56 | http_client_max_length: non_neg_integer() 57 | } 58 | end 59 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Build configuration 2 | # ------------------- 3 | 4 | APP_NAME ?= `grep -Eo 'app: :\w*' mix.exs | cut -d ':' -f 3` 5 | APP_VERSION = `grep -Eo 'version: "[0-9\.]*(-?[a-z]+[0-9]*)?"' mix.exs | cut -d '"' -f 2` 6 | 7 | # Introspection targets 8 | # --------------------- 9 | 10 | .PHONY: help 11 | help: header targets 12 | 13 | .PHONY: header 14 | header: 15 | @echo "\033[34mEnvironment\033[0m" 16 | @echo "\033[34m---------------------------------------------------------------\033[0m" 17 | @printf "\033[33m%-23s\033[0m" "APP_NAME" 18 | @printf "\033[35m%s\033[0m" $(APP_NAME) 19 | @echo "" 20 | @printf "\033[33m%-23s\033[0m" "APP_VERSION" 21 | @printf "\033[35m%s\033[0m" $(APP_VERSION) 22 | @echo "\n" 23 | 24 | .PHONY: targets 25 | targets: 26 | @echo "\033[34mTargets\033[0m" 27 | @echo "\033[34m---------------------------------------------------------------\033[0m" 28 | @perl -nle'print $& if m{^[a-zA-Z_-\d]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' 29 | 30 | 31 | 32 | # Build targets 33 | # ------------- 34 | 35 | .PHONY: prepare 36 | prepare: 37 | mix deps.get 38 | 39 | # Development targets 40 | # ------------------- 41 | 42 | .PHONY: dependencies 43 | dependencies: ## Install dependencies 44 | mix deps.get 45 | 46 | # Check, lint and format targets 47 | # ------------------------------ 48 | 49 | .PHONY: check-test 50 | check-test: 51 | mix test 52 | 53 | .PHONY: check-format 54 | check-format: 55 | mix format --dry-run --check-formatted 56 | 57 | .PHONY: check-unused-dependencies 58 | check-unused-dependencies: 59 | mix deps.unlock --check-unused 60 | 61 | .PHONY: check-typing 62 | check-typing: 63 | mix dialyzer 64 | 65 | .PHONY: check-github 66 | check-github: check-format check-unused-dependencies check-test check-typing ## Run various checks on project files and report as GitHub comment 67 | 68 | .PHONY: format 69 | format: ## Format project files 70 | mix format 71 | 72 | .PHONY: lint 73 | lint: lint-elixir ## Lint project files 74 | 75 | .PHONY: lint-elixir 76 | lint-elixir: 77 | mix compile --warnings-as-errors --force 78 | mix credo --strict 79 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.9.0" 5 | 6 | def project do 7 | [ 8 | app: :plug_image_processing, 9 | version: @version, 10 | elixir: "~> 1.13", 11 | package: package(), 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | compilers: Mix.compilers(), 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | aliases: aliases(), 17 | xref: [exclude: IEx], 18 | deps: deps(), 19 | description: "Plug to process images on-the-fly using libvips", 20 | source_url: "https://github.com/mirego/plug_image_processing", 21 | homepage_url: "https://github.com/mirego/plug_image_processing", 22 | docs: [ 23 | extras: ["README.md"], 24 | main: "readme", 25 | source_ref: "v#{@version}", 26 | source_url: "https://github.com/mirego/plug_image_processing" 27 | ] 28 | ] 29 | end 30 | 31 | def application do 32 | [mod: []] 33 | end 34 | 35 | defp elixirc_paths(:test), do: ["lib", "test/support"] 36 | defp elixirc_paths(_), do: ["lib"] 37 | 38 | defp deps do 39 | [ 40 | {:plug, "~> 1.0"}, 41 | {:vix, "~> 0.13"}, 42 | {:hackney, "~> 1.18"}, 43 | {:telemetry, "~> 1.0"}, 44 | {:jason, "~> 1.0"}, 45 | {:telemetry_metrics, "~> 0.6 or ~> 1.0"}, 46 | 47 | # Linting 48 | {:styler, "~> 1.0", only: [:dev, :test], runtime: false}, 49 | {:credo, "~> 1.1", only: [:dev, :test]}, 50 | {:credo_envvar, "~> 0.1", only: [:dev, :test], runtime: false}, 51 | {:credo_naming, "~> 2.0", only: [:dev, :test], runtime: false}, 52 | 53 | # Docs 54 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 55 | 56 | # Types 57 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false} 58 | ] 59 | end 60 | 61 | defp aliases do 62 | [] 63 | end 64 | 65 | defp package do 66 | [ 67 | maintainers: ["Simon Prévost"], 68 | licenses: ["BSD-3-Clause"], 69 | links: %{"GitHub" => "https://github.com/mirego/plug_image_processing"}, 70 | files: ~w(lib mix.exs README.md) 71 | ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/plug_image_processing/image_metadata_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.ImageMetadataTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.ImageMetadata 5 | 6 | describe "struct creation" do 7 | test "creates image metadata with all fields" do 8 | metadata = %ImageMetadata{ 9 | channels: 3, 10 | has_alpha: false, 11 | height: 512, 12 | width: 512 13 | } 14 | 15 | assert metadata.channels == 3 16 | assert metadata.has_alpha == false 17 | assert metadata.height == 512 18 | assert metadata.width == 512 19 | end 20 | 21 | test "creates image metadata with alpha channel" do 22 | metadata = %ImageMetadata{ 23 | channels: 4, 24 | has_alpha: true, 25 | height: 256, 26 | width: 256 27 | } 28 | 29 | assert metadata.channels == 4 30 | assert metadata.has_alpha == true 31 | assert metadata.height == 256 32 | assert metadata.width == 256 33 | end 34 | 35 | test "creates image metadata with nil values" do 36 | metadata = %ImageMetadata{} 37 | 38 | assert is_nil(metadata.channels) 39 | assert is_nil(metadata.has_alpha) 40 | assert is_nil(metadata.height) 41 | assert is_nil(metadata.width) 42 | end 43 | end 44 | 45 | describe "Jason encoding" do 46 | test "encodes to JSON correctly" do 47 | metadata = %ImageMetadata{ 48 | channels: 3, 49 | has_alpha: false, 50 | height: 512, 51 | width: 512 52 | } 53 | 54 | json = Jason.encode!(metadata) 55 | decoded = Jason.decode!(json) 56 | 57 | assert decoded["channels"] == 3 58 | assert decoded["has_alpha"] == false 59 | assert decoded["height"] == 512 60 | assert decoded["width"] == 512 61 | end 62 | 63 | test "encodes nil values correctly" do 64 | metadata = %ImageMetadata{} 65 | 66 | json = Jason.encode!(metadata) 67 | decoded = Jason.decode!(json) 68 | 69 | assert is_nil(decoded["channels"]) 70 | assert is_nil(decoded["has_alpha"]) 71 | assert is_nil(decoded["height"]) 72 | assert is_nil(decoded["width"]) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/echo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.EchoTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Echo 6 | alias Vix.Vips.Image 7 | 8 | setup do 9 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 10 | config = %Config{path: "/imageproxy"} 11 | {:ok, image: image, config: config} 12 | end 13 | 14 | describe "new/3" do 15 | test "creates echo operation with image", %{image: image, config: config} do 16 | params = %{} 17 | 18 | {:ok, operation} = Echo.new(image, params, config) 19 | 20 | assert %Echo{} = operation 21 | assert operation.image == image 22 | end 23 | 24 | test "creates echo operation ignoring params", %{image: image, config: config} do 25 | params = %{"width" => "100", "height" => "200", "ignored" => "value"} 26 | 27 | {:ok, operation} = Echo.new(image, params, config) 28 | 29 | assert %Echo{} = operation 30 | assert operation.image == image 31 | end 32 | 33 | test "creates echo operation with nil image", %{config: config} do 34 | params = %{} 35 | 36 | {:ok, operation} = Echo.new(nil, params, config) 37 | 38 | assert %Echo{} = operation 39 | assert operation.image == nil 40 | end 41 | end 42 | 43 | describe "PlugImageProcessing.Operation implementation" do 44 | test "valid?/1 always returns true", %{image: image, config: config} do 45 | {:ok, operation} = Echo.new(image, %{}, config) 46 | 47 | assert PlugImageProcessing.Operation.valid?(operation) == true 48 | end 49 | 50 | test "valid?/1 returns true even with nil image", %{config: config} do 51 | {:ok, operation} = Echo.new(nil, %{}, config) 52 | 53 | assert PlugImageProcessing.Operation.valid?(operation) == true 54 | end 55 | 56 | test "process/2 returns the original image unchanged", %{image: image, config: config} do 57 | {:ok, operation} = Echo.new(image, %{}, config) 58 | 59 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 60 | 61 | assert result_image == image 62 | # Same reference 63 | assert result_image === image 64 | end 65 | 66 | test "process/2 returns nil when image is nil", %{config: config} do 67 | {:ok, operation} = Echo.new(nil, %{}, config) 68 | 69 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 70 | 71 | assert result_image == nil 72 | end 73 | 74 | test "process/2 ignores config parameter", %{image: image} do 75 | {:ok, operation} = Echo.new(image, %{}, %Config{path: "/different"}) 76 | different_config = %Config{path: "/another", http_client_timeout: 5000} 77 | 78 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, different_config) 79 | 80 | assert result_image == image 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/plug_image_processing/middlewares/cache_headers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Middlewares.CacheHeadersTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Conn 5 | import Plug.Test 6 | 7 | alias PlugImageProcessing.Config 8 | alias PlugImageProcessing.Middlewares.CacheHeaders 9 | 10 | describe "enabled?" do 11 | test "returns true when http_cache_ttl is an integer" do 12 | config = %Config{path: "/imageproxy", http_cache_ttl: 3600} 13 | middleware = %CacheHeaders{config: config} 14 | conn = conn(:get, "/imageproxy/resize") 15 | 16 | assert PlugImageProcessing.Middleware.enabled?(middleware, conn) 17 | end 18 | 19 | test "returns false when http_cache_ttl is nil" do 20 | config = %Config{path: "/imageproxy", http_cache_ttl: nil} 21 | middleware = %CacheHeaders{config: config} 22 | conn = conn(:get, "/imageproxy/resize") 23 | 24 | refute PlugImageProcessing.Middleware.enabled?(middleware, conn) 25 | end 26 | 27 | test "returns false when http_cache_ttl is not an integer" do 28 | config = %Config{path: "/imageproxy", http_cache_ttl: "3600"} 29 | middleware = %CacheHeaders{config: config} 30 | conn = conn(:get, "/imageproxy/resize") 31 | 32 | refute PlugImageProcessing.Middleware.enabled?(middleware, conn) 33 | end 34 | end 35 | 36 | describe "run" do 37 | test "sets cache-control header with positive TTL" do 38 | config = %Config{path: "/imageproxy", http_cache_ttl: 3600} 39 | middleware = %CacheHeaders{config: config} 40 | conn = conn(:get, "/imageproxy/resize") 41 | 42 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 43 | 44 | assert get_resp_header(result_conn, "cache-control") == ["public, s-maxage=3600, max-age=3600, no-transform"] 45 | end 46 | 47 | test "sets no-cache header when TTL is 0" do 48 | config = %Config{path: "/imageproxy", http_cache_ttl: 0} 49 | middleware = %CacheHeaders{config: config} 50 | conn = conn(:get, "/imageproxy/resize") 51 | 52 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 53 | 54 | assert get_resp_header(result_conn, "cache-control") == ["private, no-cache, no-store, must-revalidate"] 55 | end 56 | 57 | test "sets cache-control header with different TTL values" do 58 | test_cases = [ 59 | {1800, "public, s-maxage=1800, max-age=1800, no-transform"}, 60 | {7200, "public, s-maxage=7200, max-age=7200, no-transform"}, 61 | {86_400, "public, s-maxage=86400, max-age=86400, no-transform"} 62 | ] 63 | 64 | for {ttl, expected_header} <- test_cases do 65 | config = %Config{path: "/imageproxy", http_cache_ttl: ttl} 66 | middleware = %CacheHeaders{config: config} 67 | conn = conn(:get, "/imageproxy/resize") 68 | 69 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 70 | 71 | assert get_resp_header(result_conn, "cache-control") == [expected_header] 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/plug_image_processing/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.ConfigTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | 6 | describe "struct creation" do 7 | test "creates config with required path" do 8 | config = %Config{path: "/imageproxy"} 9 | 10 | assert config.path == "/imageproxy" 11 | assert is_list(config.sources) 12 | assert is_list(config.operations) 13 | assert is_list(config.middlewares) 14 | assert config.onerror == %{} 15 | assert is_nil(config.url_signature_key) 16 | assert is_nil(config.allowed_origins) 17 | assert config.source_url_redirect_operations == [] 18 | assert config.http_client == PlugImageProcessing.Sources.HTTPClient.Hackney 19 | assert config.http_client_cache == PlugImageProcessing.Sources.HTTPClientCache.Default 20 | assert config.http_client_timeout == 10_000 21 | assert config.http_client_max_length == 1_000_000_000 22 | assert is_nil(config.http_cache_ttl) 23 | end 24 | 25 | test "creates config with custom values" do 26 | config = %Config{ 27 | path: "/custom", 28 | url_signature_key: "secret", 29 | allowed_origins: ["example.com"], 30 | http_client_timeout: 5000, 31 | http_cache_ttl: 3600 32 | } 33 | 34 | assert config.path == "/custom" 35 | assert config.url_signature_key == "secret" 36 | assert config.allowed_origins == ["example.com"] 37 | assert config.http_client_timeout == 5000 38 | assert config.http_cache_ttl == 3600 39 | end 40 | 41 | test "has default sources" do 42 | config = %Config{path: "/imageproxy"} 43 | 44 | assert PlugImageProcessing.Sources.URL in config.sources 45 | end 46 | 47 | test "has default operations" do 48 | config = %Config{path: "/imageproxy"} 49 | 50 | operations_map = Map.new(config.operations) 51 | assert operations_map[""] == PlugImageProcessing.Operations.Echo 52 | assert operations_map["crop"] == PlugImageProcessing.Operations.Crop 53 | assert operations_map["flip"] == PlugImageProcessing.Operations.Flip 54 | assert operations_map["watermarkimage"] == PlugImageProcessing.Operations.WatermarkImage 55 | assert operations_map["extract"] == PlugImageProcessing.Operations.ExtractArea 56 | assert operations_map["resize"] == PlugImageProcessing.Operations.Resize 57 | assert operations_map["smartcrop"] == PlugImageProcessing.Operations.Smartcrop 58 | assert operations_map["pipeline"] == PlugImageProcessing.Operations.Pipeline 59 | assert operations_map["info"] == PlugImageProcessing.Operations.Info 60 | end 61 | 62 | test "has default middlewares" do 63 | config = %Config{path: "/imageproxy"} 64 | 65 | assert PlugImageProcessing.Middlewares.SignatureKey in config.middlewares 66 | assert PlugImageProcessing.Middlewares.AllowedOrigins in config.middlewares 67 | assert PlugImageProcessing.Middlewares.CacheHeaders in config.middlewares 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/info_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.InfoTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.ImageMetadata 6 | alias PlugImageProcessing.Operations.Info 7 | alias Vix.Vips.Image 8 | 9 | setup do 10 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 11 | config = %Config{path: "/imageproxy"} 12 | {:ok, image: image, config: config} 13 | end 14 | 15 | describe "new/3" do 16 | test "always returns invalid operation error", %{image: image, config: config} do 17 | params = %{} 18 | 19 | result = Info.new(image, params, config) 20 | 21 | assert result == {:error, :invalid_operation} 22 | end 23 | 24 | test "returns invalid operation error with any params", %{image: image, config: config} do 25 | params = %{"width" => "100", "height" => "200"} 26 | 27 | result = Info.new(image, params, config) 28 | 29 | assert result == {:error, :invalid_operation} 30 | end 31 | 32 | test "returns invalid operation error with nil image", %{config: config} do 33 | params = %{} 34 | 35 | result = Info.new(nil, params, config) 36 | 37 | assert result == {:error, :invalid_operation} 38 | end 39 | 40 | test "returns invalid operation error with nil config", %{image: image} do 41 | params = %{} 42 | 43 | result = Info.new(image, params, nil) 44 | 45 | assert result == {:error, :invalid_operation} 46 | end 47 | end 48 | 49 | describe "PlugImageProcessing.Info implementation" do 50 | test "process/1 returns image metadata", %{image: image} do 51 | operation = %Info{image: image} 52 | 53 | {:ok, metadata} = PlugImageProcessing.Info.process(operation) 54 | 55 | assert %ImageMetadata{} = metadata 56 | assert metadata.width == Image.width(image) 57 | assert metadata.height == Image.height(image) 58 | assert metadata.channels == Image.bands(image) 59 | assert metadata.has_alpha == Image.has_alpha?(image) 60 | end 61 | 62 | test "process/1 returns correct metadata for test image", %{image: image} do 63 | operation = %Info{image: image} 64 | 65 | {:ok, metadata} = PlugImageProcessing.Info.process(operation) 66 | 67 | assert metadata.width == 512 68 | assert metadata.height == 512 69 | assert metadata.channels == 3 70 | assert metadata.has_alpha == false 71 | end 72 | 73 | test "process/1 works with different image operations" do 74 | # Use the existing test image and verify the metadata extraction works 75 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 76 | operation = %Info{image: image} 77 | 78 | {:ok, metadata} = PlugImageProcessing.Info.process(operation) 79 | 80 | assert is_integer(metadata.width) 81 | assert is_integer(metadata.height) 82 | assert is_integer(metadata.channels) 83 | assert is_boolean(metadata.has_alpha) 84 | assert metadata.width > 0 85 | assert metadata.height > 0 86 | assert metadata.channels > 0 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | common_checks = [ 2 | {Credo.Check.Consistency.ExceptionNames}, 3 | {Credo.Check.Consistency.LineEndings}, 4 | {Credo.Check.Consistency.SpaceAroundOperators}, 5 | {Credo.Check.Consistency.SpaceInParentheses}, 6 | {Credo.Check.Consistency.TabsOrSpaces}, 7 | {Credo.Check.Design.AliasUsage, if_called_more_often_than: 2, if_nested_deeper_than: 1}, 8 | {Credo.Check.Design.TagTODO}, 9 | {Credo.Check.Design.TagFIXME}, 10 | {Credo.Check.Readability.AliasOrder}, 11 | {Credo.Check.Readability.FunctionNames}, 12 | {Credo.Check.Readability.LargeNumbers}, 13 | {Credo.Check.Readability.MaxLineLength, max_length: 200}, 14 | {Credo.Check.Readability.ModuleAttributeNames}, 15 | {Credo.Check.Readability.ModuleDoc, false}, 16 | {Credo.Check.Readability.ModuleNames}, 17 | {Credo.Check.Readability.MultiAlias}, 18 | {Credo.Check.Readability.ParenthesesInCondition}, 19 | {Credo.Check.Readability.PredicateFunctionNames}, 20 | {Credo.Check.Readability.SinglePipe}, 21 | {Credo.Check.Readability.TrailingBlankLine}, 22 | {Credo.Check.Readability.TrailingWhiteSpace}, 23 | {Credo.Check.Readability.VariableNames}, 24 | {Credo.Check.Refactor.ABCSize, max_size: 80}, 25 | {Credo.Check.Refactor.CaseTrivialMatches}, 26 | {Credo.Check.Refactor.CondStatements}, 27 | {Credo.Check.Refactor.FunctionArity}, 28 | {Credo.Check.Refactor.MapInto, false}, 29 | {Credo.Check.Refactor.MatchInCondition}, 30 | {Credo.Check.Refactor.PipeChainStart, excluded_argument_types: ~w(atom binary fn keyword)a, excluded_functions: ~w(from)}, 31 | {Credo.Check.Refactor.CyclomaticComplexity, max_complexity: 12}, 32 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 33 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 34 | {Credo.Check.Refactor.Nesting}, 35 | {Credo.Check.Refactor.UnlessWithElse}, 36 | {Credo.Check.Refactor.WithClauses}, 37 | {Credo.Check.Warning.IExPry}, 38 | {Credo.Check.Warning.IoInspect}, 39 | {Credo.Check.Warning.LazyLogging, false}, 40 | {Credo.Check.Warning.OperationOnSameValues}, 41 | {Credo.Check.Warning.BoolOperationOnSameValues}, 42 | {Credo.Check.Warning.UnusedEnumOperation}, 43 | {Credo.Check.Warning.UnusedKeywordOperation}, 44 | {Credo.Check.Warning.UnusedListOperation}, 45 | {Credo.Check.Warning.UnusedStringOperation}, 46 | {Credo.Check.Warning.UnusedTupleOperation}, 47 | {Credo.Check.Warning.OperationWithConstantResult}, 48 | {CredoEnvvar.Check.Warning.EnvironmentVariablesAtCompileTime}, 49 | {CredoNaming.Check.Warning.AvoidSpecificTermsInModuleNames, terms: ["Manager", "Fetcher", "Builder", "Persister", "Serializer", ~r/^Helpers?$/i, ~r/^Utils?$/i]}, 50 | {CredoNaming.Check.Consistency.ModuleFilename, excluded_paths: ["config", "mix.exs", "priv", "test/support"]} 51 | ] 52 | 53 | %{ 54 | configs: [ 55 | %{ 56 | name: "default", 57 | strict: true, 58 | files: %{ 59 | included: ["*.exs", "lib/", "config/", "rel/"], 60 | excluded: [] 61 | }, 62 | checks: 63 | common_checks ++ 64 | [ 65 | {Credo.Check.Design.DuplicatedCode, excluded_macros: [], mass_threshold: 50} 66 | ] 67 | }, 68 | %{ 69 | name: "test", 70 | strict: true, 71 | files: %{ 72 | included: ["test/"], 73 | excluded: [] 74 | }, 75 | checks: common_checks 76 | } 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | Image server as a Plug, powered by libvips. 5 |

6 | 7 | 8 |
9 | 10 | ## Usage 11 | 12 | ### Installation 13 | 14 | PlugImageProcessing is published on Hex. Add it to your list of dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | # mix.exs 18 | def deps do 19 | [ 20 | {:plug_image_processing, ">= 0.0.1"} 21 | ] 22 | end 23 | ``` 24 | 25 | Then run mix deps.get to install the package and its dependencies. 26 | 27 | To expose a `/imageproxy` route, add the plug in your endpoint, before your router plug, but after `Plug.Parsers`: 28 | 29 | ```elixir 30 | # lib/my_app_web/endpoint.ex 31 | plug(PlugImageProcessing.Web, path: "/imageproxy") 32 | #... 33 | plug(MyAppWeb.Router) 34 | ``` 35 | 36 | ## Features 37 | 38 | ### Sources 39 | 40 | A single source for image is supported for now: the `url` query parameter. 41 | 42 | ```sh 43 | /imageproxy/resize?url=https://s3.ca-central-1.amazonaws.com/my_image.jpg&width=300 44 | ``` 45 | 46 | It will download the image from the remote location, modify it using libvips and return it to the client. 47 | 48 | ### Operations 49 | 50 | A number of operations exposed by libvips are supported by `PlugImageProcessing`. See the `PlugImageProcessing.Operations.*` module for more details. 51 | 52 | ### Requests validations 53 | 54 | Validations can be added so your endpoint is more secure. 55 | 56 | ### Signature key 57 | 58 | By adding a signature key in your config, a parameter `sign` needs to be included in the URL to validate the payload. 59 | The signature prevent a client to forge a large number of unique requests that would go through the CDN and hitting our server. 60 | 61 | ```elixir 62 | plug(PlugImageProcessing.Web, url_signature_key: "1234") 63 | ``` 64 | 65 | Then a request path like: 66 | 67 | ```sh 68 | /imageproxy/resize?url=https://s3.ca-central-1.amazonaws.com/my_image.jpg&width=300&quality=60 69 | ``` 70 | 71 | will fail because the `sign` parameter is not present. 72 | 73 | **The HMAC-SHA256 hash is created by taking the URL path (excluding the leading /), the request parameters (alphabetically-sorted and concatenated with & into a string). The hash is then base64url-encoded.** 74 | 75 | ```elixir 76 | Base.url_encode64(:crypto.mac(:hmac, :sha256, "1234", "resize" <> "quality=60&url=https://s3.ca-central-1.amazonaws.com/my_image.jpg&width=300")) 77 | # => "ku5SCH56vrsqEr-_VRDOFJHqa6AXslh3fpAelPAPoeI=" 78 | ``` 79 | 80 | Now this request will succeed! 81 | 82 | ```sh 83 | /imageproxy/resize?url=https://s3.ca-central-1.amazonaws.com/my_image.jpg&width=300&quality=60&sign=ku5SCH56vrsqEr-_VRDOFJHqa6AXslh3fpAelPAPoeI= 84 | ``` 85 | 86 | ## License 87 | 88 | `PlugImageProcessing` is © 2022 [Mirego](https://www.mirego.com) and may be freely distributed under the [New BSD license](http://opensource.org/licenses/BSD-3-Clause). See the [`LICENSE.md`](https://github.com/mirego/plug_image_processing/blob/master/LICENSE.md) file. 89 | 90 | ## About Mirego 91 | 92 | [Mirego](https://www.mirego.com) is a team of passionate people who believe that work is a place where you can innovate and have fun. We’re a team of [talented people](https://life.mirego.com) who imagine and build beautiful Web and mobile applications. We come together to share ideas and [change the world](http://www.mirego.org). 93 | 94 | We also [love open-source software](https://open.mirego.com) and we try to give back to the community as much as we can. 95 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/smartcrop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.SmartcropTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Crop 6 | alias PlugImageProcessing.Operations.Smartcrop 7 | alias Vix.Vips.Image 8 | 9 | setup do 10 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 11 | config = %Config{path: "/imageproxy"} 12 | {:ok, image: image, config: config} 13 | end 14 | 15 | describe "new/3" do 16 | test "creates smartcrop operation with valid parameters", %{image: image, config: config} do 17 | params = %{ 18 | "width" => "100", 19 | "height" => "200" 20 | } 21 | 22 | {:ok, operation} = Smartcrop.new(image, params, config) 23 | 24 | assert %Crop{} = operation 25 | assert operation.image == image 26 | assert operation.width == 100 27 | assert operation.height == 200 28 | assert operation.gravity == "smart" 29 | end 30 | 31 | test "creates smartcrop operation with integer parameters", %{image: image, config: config} do 32 | params = %{ 33 | "width" => 150, 34 | "height" => 250 35 | } 36 | 37 | {:ok, operation} = Smartcrop.new(image, params, config) 38 | 39 | assert %Crop{} = operation 40 | assert operation.image == image 41 | assert operation.width == 150 42 | assert operation.height == 250 43 | assert operation.gravity == "smart" 44 | end 45 | 46 | test "creates smartcrop operation when width is missing (uses nil)", %{image: image, config: config} do 47 | params = %{ 48 | "height" => "200" 49 | } 50 | 51 | {:ok, operation} = Smartcrop.new(image, params, config) 52 | 53 | assert %Crop{} = operation 54 | assert operation.image == image 55 | assert operation.width == nil 56 | assert operation.height == 200 57 | assert operation.gravity == "smart" 58 | end 59 | 60 | test "creates smartcrop operation when height is missing (uses nil)", %{image: image, config: config} do 61 | params = %{ 62 | "width" => "100" 63 | } 64 | 65 | {:ok, operation} = Smartcrop.new(image, params, config) 66 | 67 | assert %Crop{} = operation 68 | assert operation.image == image 69 | assert operation.width == 100 70 | assert operation.height == nil 71 | assert operation.gravity == "smart" 72 | end 73 | 74 | test "returns error when width is invalid", %{image: image, config: config} do 75 | params = %{ 76 | "width" => "invalid", 77 | "height" => "200" 78 | } 79 | 80 | result = Smartcrop.new(image, params, config) 81 | 82 | assert {:error, :bad_request} = result 83 | end 84 | 85 | test "returns error when height is invalid", %{image: image, config: config} do 86 | params = %{ 87 | "width" => "100", 88 | "height" => "invalid" 89 | } 90 | 91 | result = Smartcrop.new(image, params, config) 92 | 93 | assert {:error, :bad_request} = result 94 | end 95 | 96 | test "creates smartcrop operation when both width and height are missing (uses nil)", %{image: image, config: config} do 97 | params = %{} 98 | 99 | {:ok, operation} = Smartcrop.new(image, params, config) 100 | 101 | assert %Crop{} = operation 102 | assert operation.image == image 103 | assert operation.width == nil 104 | assert operation.height == nil 105 | assert operation.gravity == "smart" 106 | end 107 | 108 | test "returns error when both width and height are invalid", %{image: image, config: config} do 109 | params = %{ 110 | "width" => "invalid", 111 | "height" => "also_invalid" 112 | } 113 | 114 | result = Smartcrop.new(image, params, config) 115 | 116 | assert {:error, :bad_request} = result 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/plug_image_processing.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing do 2 | @moduledoc false 3 | alias PlugImageProcessing.Info 4 | alias PlugImageProcessing.Middleware 5 | alias PlugImageProcessing.Middlewares.SignatureKey 6 | alias PlugImageProcessing.Operation 7 | alias PlugImageProcessing.Source 8 | alias Vix.Vips.Image 9 | 10 | @type image :: Image.t() 11 | @type config :: PlugImageProcessing.Config.t() 12 | @type image_metadata :: PlugImageProcessing.ImageMetadata.t() 13 | 14 | @spec generate_url(String.t(), Enumerable.t(), atom(), map()) :: String.t() 15 | def generate_url(url, config, operation, query) do 16 | config = struct!(PlugImageProcessing.Config, config) 17 | 18 | uri = URI.parse(url) 19 | uri = %{uri | path: config.path <> "/#{operation}"} 20 | uri = %{uri | query: URI.encode_query(query)} 21 | 22 | uri = 23 | if Middleware.enabled?(%SignatureKey{config: config}, nil) do 24 | sign = SignatureKey.generate_signature(URI.to_string(uri), config) 25 | URI.append_query(uri, "sign=#{sign}") 26 | else 27 | uri 28 | end 29 | 30 | URI.to_string(uri) 31 | end 32 | 33 | @spec run_middlewares(Plug.Conn.t(), map()) :: Plug.Conn.t() 34 | def run_middlewares(conn, config) do 35 | Enum.reduce_while(config.middlewares, conn, fn module, conn -> 36 | middleware = struct!(module, config: config) 37 | 38 | with true <- Middleware.enabled?(middleware, conn), 39 | conn when not conn.halted <- Middleware.run(middleware, conn) do 40 | {:cont, conn} 41 | else 42 | conn when is_struct(conn, Plug.Conn) and conn.halted -> {:halt, conn} 43 | _ -> {:cont, conn} 44 | end 45 | end) 46 | end 47 | 48 | @spec params_operations(image(), map(), config()) :: {:ok, image()} | {:error, atom()} 49 | def params_operations(image, params, config) do 50 | image = 51 | Enum.reduce_while(params, image, fn {key, value}, image -> 52 | case operations(image, key, %{key => value}, config) do 53 | {:ok, image} -> {:cont, image} 54 | {:error, :invalid_operation} -> {:cont, image} 55 | error -> {:halt, error} 56 | end 57 | end) 58 | 59 | case image do 60 | %Image{} = image -> {:ok, image} 61 | error -> error 62 | end 63 | end 64 | 65 | @spec operations(image(), String.t(), map(), config()) :: {:ok, image()} | {:error, atom()} 66 | def operations(image, operation_name, params, config) do 67 | operation = 68 | Enum.find_value(config.operations, fn {name, module_name} -> 69 | operation_name === name && module_name.new(image, params, config) 70 | end) || {:error, :invalid_operation} 71 | 72 | with {:ok, operation} <- operation, 73 | true <- Operation.valid?(operation) do 74 | Operation.process(operation, config) 75 | end 76 | end 77 | 78 | @spec info(image()) :: {:ok, image_metadata()} | {:error, atom()} 79 | def info(image), do: Info.process(%PlugImageProcessing.Operations.Info{image: image}) 80 | 81 | @spec cast_operation_name(String.t(), config()) :: {:ok, String.t()} | {:error, atom()} 82 | def cast_operation_name(name, config) do 83 | if name in Enum.map(config.operations, &elem(&1, 0)) do 84 | {:ok, name} 85 | else 86 | {:error, :invalid_operation} 87 | end 88 | end 89 | 90 | @spec get_image(map(), String.t(), config()) :: {:ok, image(), String.t() | nil, String.t()} | {:error, atom()} | {:redirect, String.t()} 91 | def get_image(params, operation_name, config) do 92 | source = Enum.find_value(config.sources, &Source.cast(struct(&1), params)) 93 | 94 | if source do 95 | Source.get_image(source, operation_name, config) 96 | else 97 | {:error, :unknown_source} 98 | end 99 | end 100 | 101 | @spec write_to_buffer(image(), String.t()) :: {:ok, binary()} | {:error, term()} 102 | def write_to_buffer(image, file_extension) do 103 | Image.write_to_buffer(image, file_extension) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/extract_area_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.ExtractAreaTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Crop 6 | alias PlugImageProcessing.Operations.ExtractArea 7 | alias Vix.Vips.Image 8 | 9 | setup do 10 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 11 | config = %Config{path: "/imageproxy"} 12 | {:ok, image: image, config: config} 13 | end 14 | 15 | describe "new/3" do 16 | test "creates extract area operation with valid parameters", %{image: image, config: config} do 17 | params = %{ 18 | "width" => "100", 19 | "height" => "200", 20 | "left" => "10", 21 | "top" => "20" 22 | } 23 | 24 | {:ok, operation} = ExtractArea.new(image, params, config) 25 | 26 | assert %Crop{} = operation 27 | assert operation.image == image 28 | assert operation.width == 100 29 | assert operation.height == 200 30 | assert operation.left == 10 31 | assert operation.top == 20 32 | end 33 | 34 | test "creates extract area operation with default left and top", %{image: image, config: config} do 35 | params = %{ 36 | "width" => "100", 37 | "height" => "200" 38 | } 39 | 40 | {:ok, operation} = ExtractArea.new(image, params, config) 41 | 42 | assert %Crop{} = operation 43 | assert operation.image == image 44 | assert operation.width == 100 45 | assert operation.height == 200 46 | assert operation.left == 0 47 | assert operation.top == 0 48 | end 49 | 50 | test "creates extract area operation with integer parameters", %{image: image, config: config} do 51 | params = %{ 52 | "width" => 150, 53 | "height" => 250, 54 | "left" => 15, 55 | "top" => 25 56 | } 57 | 58 | {:ok, operation} = ExtractArea.new(image, params, config) 59 | 60 | assert %Crop{} = operation 61 | assert operation.image == image 62 | assert operation.width == 150 63 | assert operation.height == 250 64 | assert operation.left == 15 65 | assert operation.top == 25 66 | end 67 | 68 | test "creates extract area operation when width is missing (uses nil)", %{image: image, config: config} do 69 | params = %{ 70 | "height" => "200", 71 | "left" => "10", 72 | "top" => "20" 73 | } 74 | 75 | {:ok, operation} = ExtractArea.new(image, params, config) 76 | 77 | assert %Crop{} = operation 78 | assert operation.image == image 79 | assert operation.width == nil 80 | assert operation.height == 200 81 | assert operation.left == 10 82 | assert operation.top == 20 83 | end 84 | 85 | test "creates extract area operation when height is missing (uses nil)", %{image: image, config: config} do 86 | params = %{ 87 | "width" => "100", 88 | "left" => "10", 89 | "top" => "20" 90 | } 91 | 92 | {:ok, operation} = ExtractArea.new(image, params, config) 93 | 94 | assert %Crop{} = operation 95 | assert operation.image == image 96 | assert operation.width == 100 97 | assert operation.height == nil 98 | assert operation.left == 10 99 | assert operation.top == 20 100 | end 101 | 102 | test "returns error when width is invalid", %{image: image, config: config} do 103 | params = %{ 104 | "width" => "invalid", 105 | "height" => "200", 106 | "left" => "10", 107 | "top" => "20" 108 | } 109 | 110 | result = ExtractArea.new(image, params, config) 111 | 112 | assert {:error, :bad_request} = result 113 | end 114 | 115 | test "returns error when height is invalid", %{image: image, config: config} do 116 | params = %{ 117 | "width" => "100", 118 | "height" => "invalid", 119 | "left" => "10", 120 | "top" => "20" 121 | } 122 | 123 | result = ExtractArea.new(image, params, config) 124 | 125 | assert {:error, :bad_request} = result 126 | end 127 | 128 | test "returns error when left is invalid", %{image: image, config: config} do 129 | params = %{ 130 | "width" => "100", 131 | "height" => "200", 132 | "left" => "invalid", 133 | "top" => "20" 134 | } 135 | 136 | result = ExtractArea.new(image, params, config) 137 | 138 | assert {:error, :bad_request} = result 139 | end 140 | 141 | test "returns error when top is invalid", %{image: image, config: config} do 142 | params = %{ 143 | "width" => "100", 144 | "height" => "200", 145 | "left" => "10", 146 | "top" => "invalid" 147 | } 148 | 149 | result = ExtractArea.new(image, params, config) 150 | 151 | assert {:error, :bad_request} = result 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /test/plug_image_processing/middlewares/allowed_origins_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Middlewares.AllowedOriginsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Test 5 | 6 | alias PlugImageProcessing.Config 7 | alias PlugImageProcessing.Middlewares.AllowedOrigins 8 | 9 | describe "enabled?" do 10 | test "returns true when url param exists and allowed_origins is configured" do 11 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 12 | middleware = %AllowedOrigins{config: config} 13 | conn = conn(:get, "/imageproxy/resize", %{"url" => "http://example.com/image.jpg"}) 14 | 15 | assert PlugImageProcessing.Middleware.enabled?(middleware, conn) 16 | end 17 | 18 | test "returns false when url param is nil" do 19 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 20 | middleware = %AllowedOrigins{config: config} 21 | conn = conn(:get, "/imageproxy/resize", %{}) 22 | 23 | refute PlugImageProcessing.Middleware.enabled?(middleware, conn) 24 | end 25 | 26 | test "returns false when allowed_origins is nil" do 27 | config = %Config{path: "/imageproxy", allowed_origins: nil} 28 | middleware = %AllowedOrigins{config: config} 29 | conn = conn(:get, "/imageproxy/resize", %{"url" => "http://example.com/image.jpg"}) 30 | 31 | refute PlugImageProcessing.Middleware.enabled?(middleware, conn) 32 | end 33 | 34 | test "returns false when allowed_origins is not a list" do 35 | config = %Config{path: "/imageproxy", allowed_origins: "example.com"} 36 | middleware = %AllowedOrigins{config: config} 37 | conn = conn(:get, "/imageproxy/resize", %{"url" => "http://example.com/image.jpg"}) 38 | 39 | refute PlugImageProcessing.Middleware.enabled?(middleware, conn) 40 | end 41 | end 42 | 43 | describe "run" do 44 | test "allows request when origin is in allowed list" do 45 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com", "test.com"]} 46 | middleware = %AllowedOrigins{config: config} 47 | conn = conn(:get, "/imageproxy/resize", %{"url" => "http://example.com/image.jpg"}) 48 | 49 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 50 | 51 | assert result_conn == conn 52 | refute result_conn.halted 53 | end 54 | 55 | test "allows request with URL encoded URL" do 56 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 57 | middleware = %AllowedOrigins{config: config} 58 | encoded_url = URI.encode_www_form("http://example.com/image.jpg") 59 | conn = conn(:get, "/imageproxy/resize", %{"url" => encoded_url}) 60 | 61 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 62 | 63 | assert result_conn == conn 64 | refute result_conn.halted 65 | end 66 | 67 | test "blocks request when origin is not in allowed list" do 68 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 69 | middleware = %AllowedOrigins{config: config} 70 | conn = conn(:get, "/imageproxy/resize", %{"url" => "http://malicious.com/image.jpg"}) 71 | 72 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 73 | 74 | assert result_conn.status == 403 75 | assert result_conn.resp_body == "Forbidden: Unallowed origin" 76 | assert result_conn.halted 77 | end 78 | 79 | test "blocks request when URL is nil" do 80 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 81 | middleware = %AllowedOrigins{config: config} 82 | conn = conn(:get, "/imageproxy/resize", %{"url" => nil}) 83 | 84 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 85 | 86 | assert result_conn.status == 403 87 | assert result_conn.resp_body == "Forbidden: Unallowed origin" 88 | assert result_conn.halted 89 | end 90 | 91 | test "blocks request when URL cannot be parsed" do 92 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 93 | middleware = %AllowedOrigins{config: config} 94 | conn = conn(:get, "/imageproxy/resize", %{"url" => "invalid-url"}) 95 | 96 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 97 | 98 | assert result_conn.status == 403 99 | assert result_conn.resp_body == "Forbidden: Unallowed origin" 100 | assert result_conn.halted 101 | end 102 | 103 | test "blocks request when parsed URL has no host" do 104 | config = %Config{path: "/imageproxy", allowed_origins: ["example.com"]} 105 | middleware = %AllowedOrigins{config: config} 106 | conn = conn(:get, "/imageproxy/resize", %{"url" => "/relative/path.jpg"}) 107 | 108 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 109 | 110 | assert result_conn.status == 403 111 | assert result_conn.resp_body == "Forbidden: Unallowed origin" 112 | assert result_conn.halted 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/plug_image_processing/web.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Web do 2 | @moduledoc false 3 | use Plug.Builder 4 | 5 | import Plug.Conn 6 | 7 | plug(:cast_config) 8 | plug(:assign_operation_name) 9 | plug(:run_middlewares) 10 | plug(:fetch_query_params) 11 | plug(:action) 12 | 13 | def call(conn, opts) do 14 | conn 15 | |> put_private(:plug_image_processing_opts, opts) 16 | |> super(opts) 17 | end 18 | 19 | def assign_operation_name(conn, _) do 20 | config_path = conn.private.plug_image_processing_config.path 21 | 22 | if String.starts_with?(conn.request_path, config_path) do 23 | operation_name = operation_name_from_path(conn.request_path, config_path) 24 | put_private(conn, :plug_image_processing_operation_name, operation_name) 25 | else 26 | conn 27 | end 28 | end 29 | 30 | defp operation_name_from_path(request_path, config_path) do 31 | request_path 32 | |> String.trim_leading(config_path) 33 | |> String.trim_leading("/") 34 | |> String.split("/", parts: 2) 35 | |> List.first() 36 | end 37 | 38 | def action(%{private: %{plug_image_processing_operation_name: operation_name}} = conn, _opts) do 39 | :telemetry.span( 40 | [:plug_image_processing, :endpoint], 41 | %{conn: conn}, 42 | fn -> {halt(process_image(conn, operation_name, retry: true)), %{}} end 43 | ) 44 | end 45 | 46 | def action(conn, _), do: conn 47 | 48 | def run_middlewares(%{private: %{plug_image_processing_operation_name: _}} = conn, _) do 49 | PlugImageProcessing.run_middlewares(conn, conn.private.plug_image_processing_config) 50 | end 51 | 52 | def run_middlewares(conn, _), do: conn 53 | 54 | def cast_config(conn, _) do 55 | config = 56 | case conn.private.plug_image_processing_opts do 57 | {m, f} -> apply(m, f, []) 58 | {m, f, a} -> apply(m, f, a) 59 | config when is_list(config) -> config 60 | config when is_function(config, 0) -> config.() 61 | config -> raise ArgumentError, "Invalid config, expected either a keyword list, a function reference or a {module, function, args} structure. Got: #{inspect(config)}" 62 | end 63 | 64 | put_private(conn, :plug_image_processing_config, struct!(PlugImageProcessing.Config, config)) 65 | end 66 | 67 | defp process_image(conn, "info" = operation_name, opts) do 68 | with {:ok, image, _, _} <- PlugImageProcessing.get_image(conn.params, operation_name, conn.private.plug_image_processing_config), 69 | {:ok, image_metadata} <- PlugImageProcessing.info(image) do 70 | conn 71 | |> put_resp_content_type("application/json") 72 | |> send_resp(:ok, Jason.encode!(image_metadata)) 73 | else 74 | {:redirect, location} -> 75 | status = if conn.method in ~w(HEAD GET), do: :moved_permanently, else: :temporary_redirect 76 | 77 | conn 78 | |> put_resp_header("location", location) 79 | |> send_resp(status, "") 80 | |> halt() 81 | 82 | {:error, error} -> 83 | conn 84 | |> put_resp_header("cache-control", "private, no-cache, no-store, must-revalidate") 85 | |> handle_error(operation_name, error, opts) 86 | end 87 | end 88 | 89 | defp process_image(conn, operation_name, opts) do 90 | with {:ok, operation_name} <- PlugImageProcessing.cast_operation_name(operation_name, conn.private.plug_image_processing_config), 91 | {:ok, image, content_type, suffix} <- PlugImageProcessing.get_image(conn.params, operation_name, conn.private.plug_image_processing_config), 92 | {:ok, image} <- PlugImageProcessing.operations(image, operation_name, conn.params, conn.private.plug_image_processing_config), 93 | {:ok, image} <- PlugImageProcessing.params_operations(image, conn.params, conn.private.plug_image_processing_config), 94 | {:ok, binary} <- PlugImageProcessing.write_to_buffer(image, suffix) do 95 | conn = 96 | if is_binary(content_type) do 97 | put_resp_header(conn, "content-type", content_type) 98 | else 99 | conn 100 | end 101 | 102 | send_resp(conn, :ok, binary) 103 | else 104 | {:redirect, location} -> 105 | status = if conn.method in ~w(HEAD GET), do: :moved_permanently, else: :temporary_redirect 106 | 107 | conn 108 | |> put_resp_header("location", location) 109 | |> send_resp(status, "") 110 | |> halt() 111 | 112 | {:error, error} -> 113 | conn 114 | |> put_resp_header("cache-control", "private, no-cache, no-store, must-revalidate") 115 | |> handle_error(operation_name, error, opts) 116 | end 117 | end 118 | 119 | defp handle_error(conn, operation_name, error, opts) do 120 | with true <- Keyword.fetch!(opts, :retry), 121 | on_error when is_function(on_error) <- Map.get(conn.private.plug_image_processing_config.onerror, conn.params["onerror"]) do 122 | case on_error.(conn) do 123 | {:retry, conn} -> 124 | :telemetry.span( 125 | [:plug_image_processing, :endpoint, :retry], 126 | %{conn: conn}, 127 | fn -> {process_image(conn, operation_name, retry: false), %{}} end 128 | ) 129 | 130 | {:halt, conn} -> 131 | conn 132 | 133 | conn -> 134 | send_resp(conn, :bad_request, "Bad request: #{inspect(error)}") 135 | end 136 | else 137 | _ -> 138 | send_resp(conn, :bad_request, "Bad request: #{inspect(error)}") 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/flip_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.FlipTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Flip 6 | alias Vix.Vips.Image 7 | 8 | setup do 9 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 10 | config = %Config{path: "/imageproxy"} 11 | {:ok, image: image, config: config} 12 | end 13 | 14 | describe "new/3" do 15 | test "creates flip operation with default horizontal direction", %{image: image, config: config} do 16 | params = %{} 17 | 18 | {:ok, operation} = Flip.new(image, params, config) 19 | 20 | assert %Flip{} = operation 21 | assert operation.image == image 22 | assert operation.direction == :VIPS_DIRECTION_HORIZONTAL 23 | end 24 | 25 | test "creates flip operation with flip=x parameter", %{image: image, config: config} do 26 | params = %{"flip" => "x"} 27 | 28 | {:ok, operation} = Flip.new(image, params, config) 29 | 30 | assert %Flip{} = operation 31 | assert operation.image == image 32 | assert operation.direction == :VIPS_DIRECTION_HORIZONTAL 33 | end 34 | 35 | test "creates flip operation with flip=y parameter", %{image: image, config: config} do 36 | params = %{"flip" => "y"} 37 | 38 | {:ok, operation} = Flip.new(image, params, config) 39 | 40 | assert %Flip{} = operation 41 | assert operation.image == image 42 | assert operation.direction == :VIPS_DIRECTION_VERTICAL 43 | end 44 | 45 | test "creates flip operation with direction=x parameter", %{image: image, config: config} do 46 | params = %{"direction" => "x"} 47 | 48 | {:ok, operation} = Flip.new(image, params, config) 49 | 50 | assert %Flip{} = operation 51 | assert operation.image == image 52 | assert operation.direction == :VIPS_DIRECTION_HORIZONTAL 53 | end 54 | 55 | test "creates flip operation with direction=y parameter", %{image: image, config: config} do 56 | params = %{"direction" => "y"} 57 | 58 | {:ok, operation} = Flip.new(image, params, config) 59 | 60 | assert %Flip{} = operation 61 | assert operation.image == image 62 | assert operation.direction == :VIPS_DIRECTION_VERTICAL 63 | end 64 | 65 | test "creates flip operation with flip=true parameter", %{image: image, config: config} do 66 | params = %{"flip" => "true"} 67 | 68 | {:ok, operation} = Flip.new(image, params, config) 69 | 70 | assert %Flip{} = operation 71 | assert operation.image == image 72 | assert operation.direction == true 73 | end 74 | 75 | test "creates flip operation with flip=false parameter", %{image: image, config: config} do 76 | params = %{"flip" => "false"} 77 | 78 | {:ok, operation} = Flip.new(image, params, config) 79 | 80 | assert %Flip{} = operation 81 | assert operation.image == image 82 | assert operation.direction == false 83 | end 84 | 85 | test "direction parameter overrides flip parameter", %{image: image, config: config} do 86 | params = %{"flip" => "x", "direction" => "y"} 87 | 88 | {:ok, operation} = Flip.new(image, params, config) 89 | 90 | assert %Flip{} = operation 91 | assert operation.image == image 92 | assert operation.direction == :VIPS_DIRECTION_VERTICAL 93 | end 94 | 95 | test "boolean flip parameter overrides direction parameter", %{image: image, config: config} do 96 | params = %{"direction" => "y", "flip" => "true"} 97 | 98 | {:ok, operation} = Flip.new(image, params, config) 99 | 100 | assert %Flip{} = operation 101 | assert operation.image == image 102 | assert operation.direction == true 103 | end 104 | end 105 | 106 | describe "PlugImageProcessing.Operation implementation" do 107 | test "valid?/1 always returns true", %{image: image, config: config} do 108 | {:ok, operation} = Flip.new(image, %{}, config) 109 | 110 | assert PlugImageProcessing.Operation.valid?(operation) == true 111 | end 112 | 113 | test "process/2 flips image horizontally with boolean true", %{image: image, config: config} do 114 | {:ok, operation} = Flip.new(image, %{"flip" => "true"}, config) 115 | 116 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 117 | 118 | assert %Image{} = result_image 119 | assert Image.width(result_image) == Image.width(image) 120 | assert Image.height(result_image) == Image.height(image) 121 | end 122 | 123 | test "process/2 flips image horizontally with direction", %{image: image, config: config} do 124 | {:ok, operation} = Flip.new(image, %{"direction" => "x"}, config) 125 | 126 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 127 | 128 | assert %Image{} = result_image 129 | assert Image.width(result_image) == Image.width(image) 130 | assert Image.height(result_image) == Image.height(image) 131 | end 132 | 133 | test "process/2 flips image vertically", %{image: image, config: config} do 134 | {:ok, operation} = Flip.new(image, %{"direction" => "y"}, config) 135 | 136 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 137 | 138 | assert %Image{} = result_image 139 | assert Image.width(result_image) == Image.width(image) 140 | assert Image.height(result_image) == Image.height(image) 141 | end 142 | 143 | test "process/2 handles boolean false direction", %{image: image, config: config} do 144 | {:ok, operation} = Flip.new(image, %{"flip" => "false"}, config) 145 | 146 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 147 | 148 | assert %Image{} = result_image 149 | assert Image.width(result_image) == Image.width(image) 150 | assert Image.height(result_image) == Image.height(image) 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /test/plug_image_processing/middlewares/signature_key_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Middlewares.SignatureKeyTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Test 5 | 6 | alias PlugImageProcessing.Config 7 | alias PlugImageProcessing.Middlewares.SignatureKey 8 | 9 | describe "generate_signature/2" do 10 | test "generates correct signature for simple URL" do 11 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 12 | url = "/imageproxy/resize?width=100&url=http://example.com/image.jpg" 13 | 14 | signature = SignatureKey.generate_signature(url, config) 15 | 16 | expected = Base.url_encode64(:crypto.mac(:hmac, :sha256, "secret", "resizeurl=http%3A%2F%2Fexample.com%2Fimage.jpg&width=100")) 17 | assert signature == expected 18 | end 19 | 20 | test "generates signature with sorted query parameters" do 21 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 22 | url = "/imageproxy/resize?width=100&height=200&url=http://example.com/image.jpg&quality=80" 23 | 24 | signature = SignatureKey.generate_signature(url, config) 25 | 26 | expected = Base.url_encode64(:crypto.mac(:hmac, :sha256, "secret", "resizeheight=200&quality=80&url=http%3A%2F%2Fexample.com%2Fimage.jpg&width=100")) 27 | assert signature == expected 28 | end 29 | 30 | test "generates signature excluding existing sign parameter" do 31 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 32 | url = "/imageproxy/resize?width=100&url=http://example.com/image.jpg&sign=old_signature" 33 | 34 | signature = SignatureKey.generate_signature(url, config) 35 | 36 | expected = Base.url_encode64(:crypto.mac(:hmac, :sha256, "secret", "resizeurl=http%3A%2F%2Fexample.com%2Fimage.jpg&width=100")) 37 | assert signature == expected 38 | end 39 | 40 | test "generates signature for URL with no query parameters" do 41 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 42 | url = "/imageproxy/resize?" 43 | 44 | signature = SignatureKey.generate_signature(url, config) 45 | 46 | expected = Base.url_encode64(:crypto.mac(:hmac, :sha256, "secret", "resize")) 47 | assert signature == expected 48 | end 49 | 50 | test "generates signature with different operation" do 51 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 52 | url = "/imageproxy/crop?width=100&height=100&url=http://example.com/image.jpg" 53 | 54 | signature = SignatureKey.generate_signature(url, config) 55 | 56 | expected = Base.url_encode64(:crypto.mac(:hmac, :sha256, "secret", "cropheight=100&url=http%3A%2F%2Fexample.com%2Fimage.jpg&width=100")) 57 | assert signature == expected 58 | end 59 | 60 | test "generates signature with different secret key" do 61 | config = %Config{path: "/imageproxy", url_signature_key: "different_secret"} 62 | url = "/imageproxy/resize?width=100&url=http://example.com/image.jpg" 63 | 64 | signature = SignatureKey.generate_signature(url, config) 65 | 66 | expected = Base.url_encode64(:crypto.mac(:hmac, :sha256, "different_secret", "resizeurl=http%3A%2F%2Fexample.com%2Fimage.jpg&width=100")) 67 | assert signature == expected 68 | end 69 | end 70 | 71 | describe "PlugImageProcessing.Middleware implementation" do 72 | test "enabled?/2 returns true when url_signature_key is a binary" do 73 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 74 | middleware = %SignatureKey{config: config} 75 | conn = conn(:get, "/imageproxy/resize") 76 | 77 | assert PlugImageProcessing.Middleware.enabled?(middleware, conn) == true 78 | end 79 | 80 | test "enabled?/2 returns false when url_signature_key is nil" do 81 | config = %Config{path: "/imageproxy", url_signature_key: nil} 82 | middleware = %SignatureKey{config: config} 83 | conn = conn(:get, "/imageproxy/resize") 84 | 85 | assert PlugImageProcessing.Middleware.enabled?(middleware, conn) == false 86 | end 87 | 88 | test "enabled?/2 returns false when url_signature_key is not a binary" do 89 | config = %Config{path: "/imageproxy", url_signature_key: 123} 90 | middleware = %SignatureKey{config: config} 91 | conn = conn(:get, "/imageproxy/resize") 92 | 93 | assert PlugImageProcessing.Middleware.enabled?(middleware, conn) == false 94 | end 95 | 96 | test "run/2 allows request with valid signature" do 97 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 98 | middleware = %SignatureKey{config: config} 99 | 100 | # Generate valid signature 101 | url = "/imageproxy/resize?width=100&url=http://example.com/image.jpg" 102 | valid_signature = SignatureKey.generate_signature(url, config) 103 | 104 | conn = conn(:get, "/imageproxy/resize", %{"width" => "100", "url" => "http://example.com/image.jpg", "sign" => valid_signature}) 105 | 106 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 107 | 108 | assert result_conn == conn 109 | refute result_conn.halted 110 | end 111 | 112 | test "run/2 blocks request with invalid signature" do 113 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 114 | middleware = %SignatureKey{config: config} 115 | 116 | conn = conn(:get, "/imageproxy/resize", %{"width" => "100", "url" => "http://example.com/image.jpg", "sign" => "invalid_signature"}) 117 | 118 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 119 | 120 | assert result_conn.status == 401 121 | assert result_conn.resp_body == "Unauthorized: Invalid signature" 122 | assert result_conn.halted 123 | end 124 | 125 | test "run/2 blocks request with missing signature" do 126 | config = %Config{path: "/imageproxy", url_signature_key: "secret"} 127 | middleware = %SignatureKey{config: config} 128 | 129 | conn = conn(:get, "/imageproxy/resize", %{"width" => "100", "url" => "http://example.com/image.jpg"}) 130 | 131 | result_conn = PlugImageProcessing.Middleware.run(middleware, conn) 132 | 133 | assert result_conn.status == 401 134 | assert result_conn.resp_body == "Unauthorized: Invalid signature" 135 | assert result_conn.halted 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/pipeline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.PipelineTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Pipeline 6 | alias Vix.Vips.Image 7 | 8 | setup do 9 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 10 | config = %Config{path: "/imageproxy"} 11 | {:ok, image: image, config: config} 12 | end 13 | 14 | describe "new/3" do 15 | test "creates pipeline operation with valid JSON operations", %{image: image, config: config} do 16 | operations_json = 17 | Jason.encode!([ 18 | %{"operation" => "resize", "params" => %{"width" => 100}}, 19 | %{"operation" => "crop", "params" => %{"width" => 50, "height" => 50}} 20 | ]) 21 | 22 | params = %{"operations" => operations_json} 23 | 24 | {:ok, operation} = Pipeline.new(image, params, config) 25 | 26 | assert %Pipeline{} = operation 27 | assert operation.image == image 28 | assert length(operation.operations) == 2 29 | assert hd(operation.operations)["operation"] == "resize" 30 | end 31 | 32 | test "creates pipeline operation with empty operations array", %{image: image, config: config} do 33 | operations_json = Jason.encode!([]) 34 | params = %{"operations" => operations_json} 35 | 36 | {:ok, operation} = Pipeline.new(image, params, config) 37 | 38 | assert %Pipeline{} = operation 39 | assert operation.image == image 40 | assert operation.operations == [] 41 | end 42 | 43 | test "creates pipeline operation with single operation", %{image: image, config: config} do 44 | operations_json = 45 | Jason.encode!([ 46 | %{"operation" => "resize", "params" => %{"width" => 200}} 47 | ]) 48 | 49 | params = %{"operations" => operations_json} 50 | 51 | {:ok, operation} = Pipeline.new(image, params, config) 52 | 53 | assert %Pipeline{} = operation 54 | assert operation.image == image 55 | assert length(operation.operations) == 1 56 | assert hd(operation.operations)["operation"] == "resize" 57 | end 58 | 59 | test "returns error when operations parameter is missing", %{image: image, config: config} do 60 | params = %{} 61 | 62 | result = Pipeline.new(image, params, config) 63 | 64 | assert {:error, :bad_request} = result 65 | end 66 | 67 | test "returns error when operations JSON is invalid", %{image: image, config: config} do 68 | params = %{"operations" => "{invalid json}"} 69 | 70 | result = Pipeline.new(image, params, config) 71 | 72 | assert {:error, :bad_request} = result 73 | end 74 | 75 | test "returns error when operations parameter is nil", %{image: image, config: config} do 76 | params = %{"operations" => nil} 77 | 78 | result = Pipeline.new(image, params, config) 79 | 80 | assert {:error, :bad_request} = result 81 | end 82 | end 83 | 84 | describe "PlugImageProcessing.Operation implementation" do 85 | test "valid?/1 returns true when operations array has items", %{image: image, config: config} do 86 | operations_json = 87 | Jason.encode!([ 88 | %{"operation" => "resize", "params" => %{"width" => 100}} 89 | ]) 90 | 91 | params = %{"operations" => operations_json} 92 | 93 | {:ok, operation} = Pipeline.new(image, params, config) 94 | 95 | assert PlugImageProcessing.Operation.valid?(operation) == true 96 | end 97 | 98 | test "valid?/1 returns error when operations array is empty", %{image: image, config: config} do 99 | operations_json = Jason.encode!([]) 100 | params = %{"operations" => operations_json} 101 | 102 | {:ok, operation} = Pipeline.new(image, params, config) 103 | 104 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :invalid_operations} 105 | end 106 | 107 | test "process/2 executes single operation successfully", %{image: image, config: config} do 108 | operations_json = 109 | Jason.encode!([ 110 | %{"operation" => "resize", "params" => %{"width" => 100}} 111 | ]) 112 | 113 | params = %{"operations" => operations_json} 114 | 115 | {:ok, operation} = Pipeline.new(image, params, config) 116 | 117 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 118 | 119 | assert %Image{} = result_image 120 | assert Image.width(result_image) == 100 121 | end 122 | 123 | test "process/2 executes multiple operations in sequence", %{image: image, config: config} do 124 | operations_json = 125 | Jason.encode!([ 126 | %{"operation" => "resize", "params" => %{"width" => 200}}, 127 | %{"operation" => "crop", "params" => %{"width" => 100, "height" => 100}} 128 | ]) 129 | 130 | params = %{"operations" => operations_json} 131 | 132 | {:ok, operation} = Pipeline.new(image, params, config) 133 | 134 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 135 | 136 | assert %Image{} = result_image 137 | assert Image.width(result_image) == 100 138 | assert Image.height(result_image) == 100 139 | end 140 | 141 | test "process/2 stops on first error and returns error", %{image: image, config: config} do 142 | operations_json = 143 | Jason.encode!([ 144 | %{"operation" => "resize", "params" => %{"width" => 100}}, 145 | %{"operation" => "invalid_operation", "params" => %{}}, 146 | %{"operation" => "crop", "params" => %{"width" => 50, "height" => 50}} 147 | ]) 148 | 149 | params = %{"operations" => operations_json} 150 | 151 | {:ok, operation} = Pipeline.new(image, params, config) 152 | 153 | result = PlugImageProcessing.Operation.process(operation, config) 154 | 155 | assert {:error, _} = result 156 | end 157 | 158 | test "process/2 handles empty operations array", %{image: image, config: config} do 159 | operations_json = Jason.encode!([]) 160 | params = %{"operations" => operations_json} 161 | 162 | {:ok, operation} = Pipeline.new(image, params, config) 163 | 164 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 165 | 166 | assert result_image == image 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/resize_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.ResizeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Resize 6 | alias Vix.Vips.Image 7 | 8 | setup do 9 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 10 | config = %Config{path: "/imageproxy"} 11 | {:ok, image: image, config: config} 12 | end 13 | 14 | describe "new/3" do 15 | test "creates resize operation with width parameter", %{image: image, config: config} do 16 | params = %{"width" => "100"} 17 | 18 | {:ok, operation} = Resize.new(image, params, config) 19 | 20 | assert %Resize{} = operation 21 | assert operation.image == image 22 | assert operation.width == 100 23 | assert operation.height == nil 24 | end 25 | 26 | test "creates resize operation with w parameter (short form)", %{image: image, config: config} do 27 | params = %{"w" => "150"} 28 | 29 | {:ok, operation} = Resize.new(image, params, config) 30 | 31 | assert %Resize{} = operation 32 | assert operation.image == image 33 | assert operation.width == 150 34 | assert operation.height == nil 35 | end 36 | 37 | test "creates resize operation with height parameter", %{image: image, config: config} do 38 | params = %{"height" => "200"} 39 | 40 | {:ok, operation} = Resize.new(image, params, config) 41 | 42 | assert %Resize{} = operation 43 | assert operation.image == image 44 | assert operation.width == nil 45 | assert operation.height == 200 46 | end 47 | 48 | test "creates resize operation with h parameter (short form)", %{image: image, config: config} do 49 | params = %{"h" => "250"} 50 | 51 | {:ok, operation} = Resize.new(image, params, config) 52 | 53 | assert %Resize{} = operation 54 | assert operation.image == image 55 | assert operation.width == nil 56 | assert operation.height == 250 57 | end 58 | 59 | test "creates resize operation with both width and height", %{image: image, config: config} do 60 | params = %{"width" => "100", "height" => "200"} 61 | 62 | {:ok, operation} = Resize.new(image, params, config) 63 | 64 | assert %Resize{} = operation 65 | assert operation.image == image 66 | assert operation.width == 100 67 | assert operation.height == 200 68 | end 69 | 70 | test "creates resize operation with integer parameters", %{image: image, config: config} do 71 | params = %{"width" => 300, "height" => 400} 72 | 73 | {:ok, operation} = Resize.new(image, params, config) 74 | 75 | assert %Resize{} = operation 76 | assert operation.image == image 77 | assert operation.width == 300 78 | assert operation.height == 400 79 | end 80 | 81 | test "w parameter is used when width is not present", %{image: image, config: config} do 82 | params = %{"w" => "100"} 83 | 84 | {:ok, operation} = Resize.new(image, params, config) 85 | 86 | assert %Resize{} = operation 87 | assert operation.width == 100 88 | end 89 | 90 | test "h parameter is used when height is not present", %{image: image, config: config} do 91 | params = %{"h" => "100"} 92 | 93 | {:ok, operation} = Resize.new(image, params, config) 94 | 95 | assert %Resize{} = operation 96 | assert operation.height == 100 97 | end 98 | 99 | test "returns error when width parameter is invalid", %{image: image, config: config} do 100 | params = %{"width" => "invalid"} 101 | 102 | result = Resize.new(image, params, config) 103 | 104 | assert {:error, :bad_request} = result 105 | end 106 | 107 | test "returns error when height parameter is invalid", %{image: image, config: config} do 108 | params = %{"height" => "invalid"} 109 | 110 | result = Resize.new(image, params, config) 111 | 112 | assert {:error, :bad_request} = result 113 | end 114 | 115 | test "creates resize operation with no parameters", %{image: image, config: config} do 116 | params = %{} 117 | 118 | {:ok, operation} = Resize.new(image, params, config) 119 | 120 | assert %Resize{} = operation 121 | assert operation.image == image 122 | assert operation.width == nil 123 | assert operation.height == nil 124 | end 125 | end 126 | 127 | describe "PlugImageProcessing.Operation implementation" do 128 | test "valid?/1 returns true when width is present", %{image: image, config: config} do 129 | {:ok, operation} = Resize.new(image, %{"width" => "100"}, config) 130 | 131 | assert PlugImageProcessing.Operation.valid?(operation) == true 132 | end 133 | 134 | test "valid?/1 returns error when width is missing", %{image: image, config: config} do 135 | {:ok, operation} = Resize.new(image, %{"height" => "100"}, config) 136 | 137 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_width} 138 | end 139 | 140 | test "valid?/1 returns error when width is nil", %{image: image, config: config} do 141 | {:ok, operation} = Resize.new(image, %{}, config) 142 | 143 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_width} 144 | end 145 | 146 | test "process/2 resizes image with width only", %{image: image, config: config} do 147 | {:ok, operation} = Resize.new(image, %{"width" => "100"}, config) 148 | 149 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 150 | 151 | assert %Image{} = result_image 152 | assert Image.width(result_image) == 100 153 | # Height should be proportionally scaled 154 | original_ratio = Image.height(image) / Image.width(image) 155 | expected_height = round(100 * original_ratio) 156 | assert Image.height(result_image) == expected_height 157 | end 158 | 159 | test "process/2 resizes image with width and height", %{image: image, config: config} do 160 | {:ok, operation} = Resize.new(image, %{"width" => "100", "height" => "200"}, config) 161 | 162 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 163 | 164 | assert %Image{} = result_image 165 | assert Image.width(result_image) == 100 166 | assert Image.height(result_image) == 200 167 | end 168 | 169 | test "process/2 handles different aspect ratios", %{image: image, config: config} do 170 | {:ok, operation} = Resize.new(image, %{"width" => "50", "height" => "300"}, config) 171 | 172 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 173 | 174 | assert %Image{} = result_image 175 | assert Image.width(result_image) == 50 176 | assert Image.height(result_image) == 300 177 | end 178 | 179 | test "process/2 ignores config parameter", %{image: image} do 180 | {:ok, operation} = Resize.new(image, %{"width" => "100"}, %Config{path: "/different"}) 181 | different_config = %Config{path: "/another", http_client_timeout: 5000} 182 | 183 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, different_config) 184 | 185 | assert %Image{} = result_image 186 | assert Image.width(result_image) == 100 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /test/plug_image_processing/plug_image_processing_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessingTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Test 5 | 6 | alias PlugImageProcessing.Config 7 | alias Vix.Vips.Image 8 | 9 | defmodule HTTPMock do 10 | @moduledoc false 11 | @behaviour PlugImageProcessing.Sources.HTTPClient 12 | 13 | @image File.read!("test/support/image.jpg") 14 | 15 | def get("http://example.org/valid.jpg", _), do: {:ok, @image, [{"Content-type", "image/jpg"}]} 16 | def get("http://example.org/404.jpg", _), do: {:error, "404 Not found"} 17 | end 18 | 19 | setup do 20 | config = [ 21 | path: "/imageproxy", 22 | http_client: HTTPMock 23 | ] 24 | 25 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 26 | {:ok, config: config, image: image} 27 | end 28 | 29 | describe "generate_url" do 30 | test "valid", %{config: config} do 31 | url = PlugImageProcessing.generate_url("http://example.com", config, :resize, %{url: "http://bucket.com/test.jpg", width: 10}) 32 | uri = URI.parse(url) 33 | query_params = Enum.to_list(URI.query_decoder(uri.query)) 34 | 35 | assert uri.host === "example.com" 36 | assert uri.path === "/imageproxy/resize" 37 | assert {"width", "10"} in query_params 38 | assert {"url", "http://bucket.com/test.jpg"} in query_params 39 | end 40 | 41 | test "valid with signature", %{config: config} do 42 | url_signature_key = "12345" 43 | config = Keyword.put(config, :url_signature_key, url_signature_key) 44 | 45 | url = PlugImageProcessing.generate_url("http://example.com", config, :resize, %{url: "http://bucket.com/test.jpg", width: 10}) 46 | 47 | assert URI.decode_query(URI.parse(url).query)["sign"] === generate_signature_from_url(url_signature_key, "resizeurl=http%3A%2F%2Fbucket.com%2Ftest.jpg&width=10") 48 | end 49 | end 50 | 51 | describe "run_middlewares" do 52 | test "runs enabled middlewares", %{config: config} do 53 | config_struct = struct!(Config, Keyword.put(config, :url_signature_key, "secret")) 54 | conn = conn(:get, "/imageproxy/resize", %{"width" => "100", "url" => "http://example.org/valid.jpg"}) 55 | 56 | result_conn = PlugImageProcessing.run_middlewares(conn, config_struct) 57 | 58 | assert result_conn.halted 59 | assert result_conn.status == 401 60 | end 61 | 62 | test "skips disabled middlewares", %{config: config} do 63 | config_struct = struct!(Config, config) 64 | conn = conn(:get, "/imageproxy/resize", %{"width" => "100", "url" => "http://example.org/valid.jpg"}) 65 | 66 | result_conn = PlugImageProcessing.run_middlewares(conn, config_struct) 67 | 68 | assert result_conn == conn 69 | refute result_conn.halted 70 | end 71 | end 72 | 73 | describe "params_operations" do 74 | test "processes valid operations", %{config: config, image: image} do 75 | config_struct = struct!(Config, config) 76 | params = %{"width" => "100", "height" => "200"} 77 | 78 | {:ok, result_image} = PlugImageProcessing.params_operations(image, params, config_struct) 79 | 80 | assert %Image{} = result_image 81 | end 82 | 83 | test "ignores invalid operations", %{config: config, image: image} do 84 | config_struct = struct!(Config, config) 85 | params = %{"width" => "100", "invalid_param" => "value"} 86 | 87 | {:ok, result_image} = PlugImageProcessing.params_operations(image, params, config_struct) 88 | 89 | assert %Image{} = result_image 90 | end 91 | 92 | test "continues processing when single operation fails", %{config: config, image: image} do 93 | config_struct = struct!(Config, config) 94 | params = %{"width" => "invalid", "height" => "100"} 95 | 96 | {:ok, result_image} = PlugImageProcessing.params_operations(image, params, config_struct) 97 | 98 | assert %Image{} = result_image 99 | end 100 | end 101 | 102 | describe "operations" do 103 | test "processes valid operation", %{config: config, image: image} do 104 | config_struct = struct!(Config, config) 105 | 106 | {:ok, result_image} = PlugImageProcessing.operations(image, "resize", %{"width" => "100"}, config_struct) 107 | 108 | assert %Image{} = result_image 109 | assert Image.width(result_image) == 100 110 | end 111 | 112 | test "returns error for invalid operation name", %{config: config, image: image} do 113 | config_struct = struct!(Config, config) 114 | 115 | result = PlugImageProcessing.operations(image, "invalid_operation", %{}, config_struct) 116 | 117 | assert {:error, :invalid_operation} = result 118 | end 119 | 120 | test "returns error for invalid operation", %{config: config, image: image} do 121 | config_struct = struct!(Config, config) 122 | 123 | result = PlugImageProcessing.operations(image, "resize", %{}, config_struct) 124 | 125 | assert {:error, :missing_width} = result 126 | end 127 | end 128 | 129 | describe "info" do 130 | test "returns image metadata", %{image: image} do 131 | {:ok, metadata} = PlugImageProcessing.info(image) 132 | 133 | assert metadata.width == Image.width(image) 134 | assert metadata.height == Image.height(image) 135 | assert metadata.channels == Image.bands(image) 136 | assert metadata.has_alpha == Image.has_alpha?(image) 137 | end 138 | end 139 | 140 | describe "cast_operation_name" do 141 | test "returns ok for valid operation name", %{config: config} do 142 | config_struct = struct!(Config, config) 143 | 144 | assert PlugImageProcessing.cast_operation_name("resize", config_struct) == {:ok, "resize"} 145 | assert PlugImageProcessing.cast_operation_name("crop", config_struct) == {:ok, "crop"} 146 | assert PlugImageProcessing.cast_operation_name("", config_struct) == {:ok, ""} 147 | end 148 | 149 | test "returns error for invalid operation name", %{config: config} do 150 | config_struct = struct!(Config, config) 151 | 152 | assert PlugImageProcessing.cast_operation_name("invalid", config_struct) == {:error, :invalid_operation} 153 | end 154 | end 155 | 156 | describe "get_image" do 157 | test "returns image for valid URL source", %{config: config} do 158 | config_struct = struct!(Config, config) 159 | params = %{"url" => "http://example.org/valid.jpg"} 160 | 161 | {:ok, image, _, _} = PlugImageProcessing.get_image(params, "resize", config_struct) 162 | 163 | assert %Image{} = image 164 | end 165 | 166 | test "returns error for invalid URL source", %{config: config} do 167 | config_struct = struct!(Config, config) 168 | params = %{"url" => "http://example.org/404.jpg"} 169 | 170 | result = PlugImageProcessing.get_image(params, "resize", config_struct) 171 | 172 | assert {:error, :invalid_file} = result 173 | end 174 | 175 | test "returns error for unknown source", %{config: config} do 176 | config_struct = struct!(Config, config) 177 | params = %{"unknown_source" => "value"} 178 | 179 | result = PlugImageProcessing.get_image(params, "resize", config_struct) 180 | 181 | assert {:error, :unknown_source} = result 182 | end 183 | end 184 | 185 | describe "write_to_buffer" do 186 | test "writes image to buffer", %{image: image} do 187 | {:ok, buffer} = PlugImageProcessing.write_to_buffer(image, ".jpg") 188 | 189 | assert is_binary(buffer) 190 | assert byte_size(buffer) > 0 191 | end 192 | 193 | test "returns error for invalid format", %{image: image} do 194 | result = PlugImageProcessing.write_to_buffer(image, ".invalid") 195 | 196 | assert {:error, _} = result 197 | end 198 | end 199 | 200 | defp generate_signature_from_url(url_signature_key, url) do 201 | Base.url_encode64(:crypto.mac(:hmac, :sha256, url_signature_key, url)) 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/plug_image_processing/sources/url.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Sources.URL do 2 | @moduledoc false 3 | alias PlugImageProcessing.Options 4 | 5 | defstruct uri: nil, params: nil 6 | 7 | @type t :: %__MODULE__{} 8 | 9 | @types_extensions_mapping %{ 10 | "jpg" => ".jpg", 11 | "jpeg" => ".jpg", 12 | "png" => ".png", 13 | "webp" => ".webp", 14 | "gif" => ".gif", 15 | "svg" => ".svg", 16 | "svg+xml" => ".svg" 17 | } 18 | 19 | @valid_types Map.keys(@types_extensions_mapping) 20 | @valid_extensions Map.values(@types_extensions_mapping) 21 | @extensions_types_mapping Map.new(@types_extensions_mapping, fn {k, v} -> {v, k} end) 22 | 23 | def fetch_body(source, http_client_timeout, http_client_max_length, http_client, http_client_cache) do 24 | metadata = %{uri: source.uri} 25 | url = URI.to_string(source.uri) 26 | 27 | response = 28 | cond do 29 | http_client_cache.invalid_source?(source) -> 30 | :telemetry.execute( 31 | [:plug_image_processing, :source, :url, :invalid_source], 32 | metadata 33 | ) 34 | 35 | {:cached_error, url} 36 | 37 | response = http_client_cache.fetch_source(source) -> 38 | :telemetry.execute( 39 | [:plug_image_processing, :source, :url, :cached_source], 40 | metadata 41 | ) 42 | 43 | response 44 | 45 | true -> 46 | http_get_task = Task.async(fn -> http_client.get(url, http_client_max_length) end) 47 | 48 | case Task.yield(http_get_task, http_client_timeout) || Task.shutdown(http_get_task) do 49 | nil -> 50 | {:http_timeout, "Timeout (#{http_client_timeout}ms) on #{url}"} 51 | 52 | {:exit, reason} -> 53 | {:http_exit, "Exit with #{reason} (#{http_client_timeout}ms) on #{url}"} 54 | 55 | {:ok, result} -> 56 | http_client_cache.put_source(source, result) 57 | result 58 | end 59 | end 60 | 61 | with {:ok, body, headers} <- response do 62 | content_type = 63 | case List.keyfind(headers, "Content-Type", 0) do 64 | {_, value} -> value 65 | _ -> "" 66 | end 67 | 68 | case get_file_suffix(source, content_type) do 69 | {:invalid_file_type, type} -> 70 | {:file_type_error, "Invalid file type: #{type}"} 71 | 72 | {content_type, file_suffix} -> 73 | {:ok, body, content_type, file_suffix} 74 | end 75 | end 76 | end 77 | 78 | defp get_file_suffix_from_http_header(content_type) do 79 | content_type = String.trim_leading(content_type, "image/") 80 | 81 | if content_type in @valid_types do 82 | content_type 83 | end 84 | end 85 | 86 | defp get_file_suffix_from_query_params(params) do 87 | if params["type"] in @valid_types do 88 | params["type"] 89 | end 90 | end 91 | 92 | defp get_file_suffix_from_uri(uri) do 93 | case uri.path && Path.extname(uri.path) do 94 | "." <> content_type -> content_type 95 | _ -> nil 96 | end 97 | end 98 | 99 | defp get_file_suffix(source, content_type) do 100 | image_type = get_file_suffix_from_query_params(source.params) 101 | # If "type" query param is not found or is invalid, fallback to HTTP header 102 | image_type = image_type || get_file_suffix_from_http_header(content_type) 103 | # If HTTP header "Content-Type" is not found or is invalid, fallback to source uri 104 | image_type = image_type || get_file_suffix_from_uri(source.uri) 105 | 106 | type = Map.get(@types_extensions_mapping, image_type) 107 | 108 | case type do 109 | ".gif" -> 110 | options = 111 | [{"strip", Options.cast_boolean(source.params["stripmeta"])}] 112 | |> Options.build() 113 | |> Options.encode_suffix() 114 | 115 | {"image/gif", type <> options} 116 | 117 | # Since libvips can read SVG format but not serve it, we just convert the SVG into a PNG. 118 | ".svg" -> 119 | options = 120 | [{"strip", Options.cast_boolean(source.params["stripmeta"])}] 121 | |> Options.build() 122 | |> Options.encode_suffix() 123 | 124 | {"image/png", ".png" <> options} 125 | 126 | extension_name when extension_name in @valid_extensions -> 127 | content_type = Map.get(@extensions_types_mapping, extension_name) 128 | 129 | if content_type do 130 | options = 131 | [ 132 | {"Q", Options.cast_integer(source.params["quality"])}, 133 | {"strip", Options.cast_boolean(source.params["stripmeta"])} 134 | ] 135 | |> Options.build() 136 | |> Options.encode_suffix() 137 | 138 | {"image/#{content_type}", type <> options} 139 | else 140 | {:invalid_file_type, extension_name} 141 | end 142 | 143 | _ -> 144 | {:invalid_file_type, image_type} 145 | end 146 | end 147 | 148 | defimpl PlugImageProcessing.Source do 149 | alias PlugImageProcessing.Sources.URL 150 | 151 | require Logger 152 | 153 | def get_image(source, operation_name, config) do 154 | with :ok <- maybe_redirect(source, operation_name, config), 155 | {:ok, body, content_type, file_suffix} when is_binary(file_suffix) and is_binary(body) <- fetch_remote_image(source, config), 156 | {:ok, image} <- Vix.Vips.Image.new_from_buffer(body, buffer_options(content_type)) do 157 | {:ok, image, content_type, file_suffix} 158 | else 159 | {:http_timeout, message} -> 160 | Logger.error("[PlugImageProcessing] - Timeout while fetching source URL: #{message}") 161 | {:error, :timeout} 162 | 163 | {:error, message} -> 164 | Logger.error("[PlugImageProcessing] - Error while fetching source URL: #{message}") 165 | {:error, :invalid_file} 166 | 167 | {:cached_error, url} -> 168 | Logger.error("[PlugImageProcessing] - Cached error on #{url}") 169 | {:error, :invalid_file} 170 | 171 | {:file_type_error, message} -> 172 | Logger.error("[PlugImageProcessing] - File type error while fetching source URL. Got #{message} on #{source.uri}") 173 | {:error, :invalid_file_type} 174 | 175 | {:http_error, status} -> 176 | Logger.error("[PlugImageProcessing] - HTTP error while fetching source URL. Got #{status} on #{source.uri}") 177 | {:error, :invalid_file} 178 | 179 | {:redirect, url} -> 180 | {:redirect, url} 181 | 182 | error -> 183 | Logger.error("[PlugImageProcessing] - Unable to fetch source URL: #{inspect(error)}") 184 | {:error, :invalid_file} 185 | end 186 | end 187 | 188 | defp maybe_redirect(source, operation_name, config) do 189 | if operation_name in config.source_url_redirect_operations do 190 | {:redirect, to_string(source.uri)} 191 | else 192 | :ok 193 | end 194 | end 195 | 196 | defp buffer_options("image/gif"), do: [n: -1] 197 | defp buffer_options(_), do: [] 198 | 199 | defp fetch_remote_image(source, config) do 200 | metadata = %{uri: source.uri} 201 | 202 | :telemetry.span( 203 | [:plug_image_processing, :source, :url, :request], 204 | metadata, 205 | fn -> 206 | result = URL.fetch_body(source, config.http_client_timeout, config.http_client_max_length, config.http_client, config.http_client_cache) 207 | {result, %{}} 208 | end 209 | ) 210 | end 211 | 212 | def cast(source, params) do 213 | with url when not is_nil(url) <- params["url"], 214 | url = URI.decode_www_form(url), 215 | uri when not is_nil(uri.host) <- URI.parse(url) do 216 | struct!(source, uri: uri, params: params) 217 | else 218 | _ -> false 219 | end 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/watermark_image_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.WatermarkImageTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.WatermarkImage 6 | alias Vix.Vips.Image 7 | 8 | defmodule HTTPMock do 9 | @moduledoc false 10 | @behaviour PlugImageProcessing.Sources.HTTPClient 11 | 12 | @image File.read!("test/support/image.jpg") 13 | 14 | def get("http://example.org/watermark.jpg", _), do: {:ok, @image, [{"Content-type", "image/jpg"}]} 15 | def get("http://example.org/404.jpg", _), do: {:error, "404 Not found"} 16 | end 17 | 18 | setup do 19 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 20 | config = %Config{path: "/imageproxy", http_client: HTTPMock} 21 | {:ok, image: image, config: config} 22 | end 23 | 24 | describe "new/3" do 25 | test "creates watermark operation with valid parameters", %{image: image, config: config} do 26 | params = %{ 27 | "image" => "http://example.org/watermark.jpg", 28 | "left" => "10", 29 | "top" => "20", 30 | "right" => "30", 31 | "bottom" => "40" 32 | } 33 | 34 | {:ok, operation} = WatermarkImage.new(image, params, config) 35 | 36 | assert %WatermarkImage{} = operation 37 | assert operation.image == image 38 | assert %Image{} = operation.sub 39 | assert operation.left == 10 40 | assert operation.top == 20 41 | assert operation.right == 30 42 | assert operation.bottom == 40 43 | end 44 | 45 | test "creates watermark operation with integer parameters", %{image: image, config: config} do 46 | params = %{ 47 | "image" => "http://example.org/watermark.jpg", 48 | "left" => 15, 49 | "top" => 25, 50 | "right" => 35, 51 | "bottom" => 45 52 | } 53 | 54 | {:ok, operation} = WatermarkImage.new(image, params, config) 55 | 56 | assert %WatermarkImage{} = operation 57 | assert operation.image == image 58 | assert %Image{} = operation.sub 59 | assert operation.left == 15 60 | assert operation.top == 25 61 | assert operation.right == 35 62 | assert operation.bottom == 45 63 | end 64 | 65 | test "returns error when watermark image URL is invalid", %{image: image, config: config} do 66 | params = %{ 67 | "image" => "http://example.org/404.jpg", 68 | "left" => "10", 69 | "top" => "20", 70 | "right" => "30", 71 | "bottom" => "40" 72 | } 73 | 74 | result = WatermarkImage.new(image, params, config) 75 | 76 | assert {:error, :invalid_file} = result 77 | end 78 | 79 | test "returns error when left parameter is invalid", %{image: image, config: config} do 80 | params = %{ 81 | "image" => "http://example.org/watermark.jpg", 82 | "left" => "invalid", 83 | "top" => "20", 84 | "right" => "30", 85 | "bottom" => "40" 86 | } 87 | 88 | result = WatermarkImage.new(image, params, config) 89 | 90 | assert {:error, :bad_request} = result 91 | end 92 | 93 | test "returns error when top parameter is invalid", %{image: image, config: config} do 94 | params = %{ 95 | "image" => "http://example.org/watermark.jpg", 96 | "left" => "10", 97 | "top" => "invalid", 98 | "right" => "30", 99 | "bottom" => "40" 100 | } 101 | 102 | result = WatermarkImage.new(image, params, config) 103 | 104 | assert {:error, :bad_request} = result 105 | end 106 | 107 | test "returns error when right parameter is invalid", %{image: image, config: config} do 108 | params = %{ 109 | "image" => "http://example.org/watermark.jpg", 110 | "left" => "10", 111 | "top" => "20", 112 | "right" => "invalid", 113 | "bottom" => "40" 114 | } 115 | 116 | result = WatermarkImage.new(image, params, config) 117 | 118 | assert {:error, :bad_request} = result 119 | end 120 | 121 | test "returns error when bottom parameter is invalid", %{image: image, config: config} do 122 | params = %{ 123 | "image" => "http://example.org/watermark.jpg", 124 | "left" => "10", 125 | "top" => "20", 126 | "right" => "30", 127 | "bottom" => "invalid" 128 | } 129 | 130 | result = WatermarkImage.new(image, params, config) 131 | 132 | assert {:error, :bad_request} = result 133 | end 134 | end 135 | 136 | describe "PlugImageProcessing.Operation implementation" do 137 | test "valid?/1 returns true when sub image exists", %{image: image, config: config} do 138 | params = %{ 139 | "image" => "http://example.org/watermark.jpg", 140 | "left" => "10", 141 | "top" => "20", 142 | "right" => "30", 143 | "bottom" => "40" 144 | } 145 | 146 | {:ok, operation} = WatermarkImage.new(image, params, config) 147 | 148 | assert PlugImageProcessing.Operation.valid?(operation) == true 149 | end 150 | 151 | test "valid?/1 returns error when sub image is nil" do 152 | operation = %WatermarkImage{ 153 | image: nil, 154 | sub: nil, 155 | left: 10, 156 | top: 20, 157 | right: 30, 158 | bottom: 40 159 | } 160 | 161 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_image} 162 | end 163 | 164 | test "process/2 composites watermark with left and top positioning", %{image: image, config: config} do 165 | params = %{ 166 | "image" => "http://example.org/watermark.jpg", 167 | "left" => "10", 168 | "top" => "20" 169 | } 170 | 171 | {:ok, operation} = WatermarkImage.new(image, params, config) 172 | 173 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 174 | 175 | assert %Image{} = result_image 176 | assert Image.width(result_image) == Image.width(image) 177 | assert Image.height(result_image) == Image.height(image) 178 | end 179 | 180 | test "process/2 composites watermark with right and bottom positioning", %{image: image, config: config} do 181 | params = %{ 182 | "image" => "http://example.org/watermark.jpg", 183 | "right" => "30", 184 | "bottom" => "40" 185 | } 186 | 187 | {:ok, operation} = WatermarkImage.new(image, params, config) 188 | 189 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 190 | 191 | assert %Image{} = result_image 192 | assert Image.width(result_image) == Image.width(image) 193 | assert Image.height(result_image) == Image.height(image) 194 | end 195 | 196 | test "process/2 composites watermark with default positioning (0,0)", %{image: image, config: config} do 197 | params = %{ 198 | "image" => "http://example.org/watermark.jpg" 199 | } 200 | 201 | {:ok, operation} = WatermarkImage.new(image, params, config) 202 | 203 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 204 | 205 | assert %Image{} = result_image 206 | assert Image.width(result_image) == Image.width(image) 207 | assert Image.height(result_image) == Image.height(image) 208 | end 209 | 210 | test "process/2 handles mixed positioning parameters", %{image: image, config: config} do 211 | params = %{ 212 | "image" => "http://example.org/watermark.jpg", 213 | "left" => "10", 214 | "bottom" => "40" 215 | } 216 | 217 | {:ok, operation} = WatermarkImage.new(image, params, config) 218 | 219 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 220 | 221 | assert %Image{} = result_image 222 | assert Image.width(result_image) == Image.width(image) 223 | assert Image.height(result_image) == Image.height(image) 224 | end 225 | end 226 | end 227 | -------------------------------------------------------------------------------- /test/plug_image_processing/options_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.OptionsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Options 6 | 7 | defmodule HTTPMock do 8 | @moduledoc false 9 | @behaviour PlugImageProcessing.Sources.HTTPClient 10 | 11 | @image File.read!("test/support/image.jpg") 12 | 13 | def get("http://example.org/valid.jpg", _), do: {:ok, @image, [{"Content-type", "image/jpg"}]} 14 | def get("http://example.org/404.jpg", _), do: {:error, "404 Not found"} 15 | end 16 | 17 | setup do 18 | config = %Config{path: "/imageproxy", http_client: HTTPMock} 19 | {:ok, config: config} 20 | end 21 | 22 | describe "build/1" do 23 | test "builds options from list with ok tuples" do 24 | options = [ 25 | {"width", {:ok, 100}}, 26 | {"height", {:ok, 200}}, 27 | {"quality", {:ok, 80}} 28 | ] 29 | 30 | result = Options.build(options) 31 | 32 | assert result == [{"width", 100}, {"height", 200}, {"quality", 80}] 33 | end 34 | 35 | test "builds options from list with direct values" do 36 | options = [ 37 | {"width", 100}, 38 | {"height", 200}, 39 | {"quality", 80} 40 | ] 41 | 42 | result = Options.build(options) 43 | 44 | assert result == [{"width", 100}, {"height", 200}, {"quality", 80}] 45 | end 46 | 47 | test "builds options from mixed list" do 48 | options = [ 49 | {"width", {:ok, 100}}, 50 | {"height", 200}, 51 | {"quality", {:ok, 80}} 52 | ] 53 | 54 | result = Options.build(options) 55 | 56 | assert result == [{"width", 100}, {"height", 200}, {"quality", 80}] 57 | end 58 | 59 | test "filters out nil values" do 60 | options = [ 61 | {"width", {:ok, 100}}, 62 | {"height", nil}, 63 | {"quality", {:ok, 80}}, 64 | {"format", nil} 65 | ] 66 | 67 | result = Options.build(options) 68 | 69 | assert result == [{"width", 100}, {"quality", 80}] 70 | end 71 | 72 | test "filters out invalid entries" do 73 | options = [ 74 | {"width", {:ok, 100}}, 75 | :invalid_entry, 76 | {"height", 200}, 77 | nil 78 | ] 79 | 80 | result = Options.build(options) 81 | 82 | assert result == [{"width", 100}, {"height", 200}] 83 | end 84 | 85 | test "handles empty list" do 86 | options = [] 87 | 88 | result = Options.build(options) 89 | 90 | assert result == [] 91 | end 92 | end 93 | 94 | describe "encode_suffix/1" do 95 | test "encodes options to suffix format" do 96 | options = [{"width", 100}, {"height", 200}, {"quality", 80}] 97 | 98 | result = Options.encode_suffix(options) 99 | 100 | assert result == "[width=100,height=200,quality=80]" 101 | end 102 | 103 | test "encodes single option" do 104 | options = [{"width", 100}] 105 | 106 | result = Options.encode_suffix(options) 107 | 108 | assert result == "[width=100]" 109 | end 110 | 111 | test "returns empty string for empty options" do 112 | options = [] 113 | 114 | result = Options.encode_suffix(options) 115 | 116 | assert result == "" 117 | end 118 | 119 | test "handles options with string values" do 120 | options = [{"format", "jpg"}, {"gravity", "center"}] 121 | 122 | result = Options.encode_suffix(options) 123 | 124 | assert result == "[format=jpg,gravity=center]" 125 | end 126 | end 127 | 128 | describe "cast_direction/2" do 129 | test "casts 'x' to horizontal direction" do 130 | assert Options.cast_direction("x") == {:ok, :VIPS_DIRECTION_HORIZONTAL} 131 | end 132 | 133 | test "casts 'y' to vertical direction" do 134 | assert Options.cast_direction("y") == {:ok, :VIPS_DIRECTION_VERTICAL} 135 | end 136 | 137 | test "returns default for unknown value" do 138 | assert Options.cast_direction("unknown") == {:ok, nil} 139 | assert Options.cast_direction("unknown", :default) == {:ok, :default} 140 | end 141 | 142 | test "returns default for nil value" do 143 | assert Options.cast_direction(nil) == {:ok, nil} 144 | assert Options.cast_direction(nil, :default) == {:ok, :default} 145 | end 146 | end 147 | 148 | describe "cast_boolean/2" do 149 | test "casts 'true' to boolean true" do 150 | assert Options.cast_boolean("true") == {:ok, true} 151 | end 152 | 153 | test "casts 'false' to boolean false" do 154 | assert Options.cast_boolean("false") == {:ok, false} 155 | end 156 | 157 | test "returns default for unknown value" do 158 | assert Options.cast_boolean("unknown") == {:ok, nil} 159 | assert Options.cast_boolean("unknown", :default) == {:ok, :default} 160 | end 161 | 162 | test "returns default for nil value" do 163 | assert Options.cast_boolean(nil) == {:ok, nil} 164 | assert Options.cast_boolean(nil, :default) == {:ok, :default} 165 | end 166 | end 167 | 168 | describe "cast_remote_image/3" do 169 | test "casts valid remote image URL", %{config: config} do 170 | {:ok, image} = Options.cast_remote_image("http://example.org/valid.jpg", "test", config) 171 | 172 | assert %Vix.Vips.Image{} = image 173 | end 174 | 175 | test "returns error for invalid remote image URL", %{config: config} do 176 | result = Options.cast_remote_image("http://example.org/404.jpg", "test", config) 177 | 178 | assert {:error, :invalid_file} = result 179 | end 180 | 181 | test "returns error for nil URL", %{config: config} do 182 | result = Options.cast_remote_image(nil, "test", config) 183 | 184 | assert result == false 185 | end 186 | end 187 | 188 | describe "cast_integer/2" do 189 | test "casts nil to default value" do 190 | assert Options.cast_integer(nil) == {:ok, nil} 191 | assert Options.cast_integer(nil, 100) == {:ok, 100} 192 | end 193 | 194 | test "returns integer value as-is" do 195 | assert Options.cast_integer(42) == {:ok, 42} 196 | assert Options.cast_integer(0) == {:ok, 0} 197 | assert Options.cast_integer(-10) == {:ok, -10} 198 | end 199 | 200 | test "parses string integers" do 201 | assert Options.cast_integer("42") == {:ok, 42} 202 | assert Options.cast_integer("0") == {:ok, 0} 203 | assert Options.cast_integer("-10") == {:ok, -10} 204 | end 205 | 206 | test "parses string integers with trailing characters" do 207 | assert Options.cast_integer("42px") == {:ok, 42} 208 | assert Options.cast_integer("100%") == {:ok, 100} 209 | end 210 | 211 | test "returns error for invalid strings" do 212 | assert Options.cast_integer("invalid") == {:error, :bad_request} 213 | assert Options.cast_integer("") == {:error, :bad_request} 214 | assert Options.cast_integer("abc123") == {:error, :bad_request} 215 | end 216 | end 217 | 218 | describe "cast_json/1" do 219 | test "returns error for nil" do 220 | assert Options.cast_json(nil) == {:error, :bad_request} 221 | end 222 | 223 | test "parses valid JSON" do 224 | json = Jason.encode!(%{"key" => "value", "number" => 42}) 225 | 226 | assert Options.cast_json(json) == {:ok, %{"key" => "value", "number" => 42}} 227 | end 228 | 229 | test "parses JSON array" do 230 | json = Jason.encode!([1, 2, 3]) 231 | 232 | assert Options.cast_json(json) == {:ok, [1, 2, 3]} 233 | end 234 | 235 | test "returns error for invalid JSON" do 236 | assert Options.cast_json("{invalid json}") == {:error, :bad_request} 237 | assert Options.cast_json("not json at all") == {:error, :bad_request} 238 | assert Options.cast_json("{") == {:error, :bad_request} 239 | end 240 | 241 | test "handles empty JSON objects and arrays" do 242 | assert Options.cast_json("{}") == {:ok, %{}} 243 | assert Options.cast_json("[]") == {:ok, []} 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, 4 | "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, 5 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 6 | "credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"}, 7 | "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 11 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 12 | "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, 13 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 14 | "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, 15 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 16 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 17 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 21 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 22 | "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, 23 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 24 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 25 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 26 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 27 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 28 | "styler": {:hex, :styler, "1.7.0", "3336e7547b5edaa3c5f7c861d8a45e1a450f9ed4db7d8f27ad54c12ab791bb66", [:mix], [], "hexpm", "ede783606fcd9bf06edfddbbedc36cc3a3af7b042cf0df8503aec0f82e016437"}, 29 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 30 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, 32 | "vix": {:hex, :vix, "0.35.0", "f6319b715e3b072e53eba456a21af5f2ff010a7a7b19b884600ea98a0609b18c", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "a3e80067a89d0631b6cf2b93594e03c1b303a2c7cddbbdd28040750d521984e5"}, 33 | } 34 | -------------------------------------------------------------------------------- /test/plug_image_processing/operations/crop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Operations.CropTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Operations.Crop 6 | alias Vix.Vips.Image 7 | 8 | setup do 9 | {:ok, image} = Image.new_from_file("test/support/image.jpg") 10 | config = %Config{path: "/imageproxy"} 11 | {:ok, image: image, config: config} 12 | end 13 | 14 | describe "new/3" do 15 | test "creates crop operation with all parameters", %{image: image, config: config} do 16 | params = %{ 17 | "width" => "100", 18 | "height" => "200", 19 | "left" => "10", 20 | "top" => "20" 21 | } 22 | 23 | {:ok, operation} = Crop.new(image, params, config) 24 | 25 | assert %Crop{} = operation 26 | assert operation.image == image 27 | assert operation.width == 100 28 | assert operation.height == 200 29 | assert operation.left == 10 30 | assert operation.top == 20 31 | assert operation.gravity == nil 32 | end 33 | 34 | test "creates crop operation with gravity parameter", %{image: image, config: config} do 35 | params = %{ 36 | "width" => "100", 37 | "height" => "200", 38 | "left" => "10", 39 | "top" => "20", 40 | "gravity" => "smart" 41 | } 42 | 43 | {:ok, operation} = Crop.new(image, params, config) 44 | 45 | assert %Crop{} = operation 46 | assert operation.image == image 47 | assert operation.width == 100 48 | assert operation.height == 200 49 | assert operation.left == 10 50 | assert operation.top == 20 51 | assert operation.gravity == "smart" 52 | end 53 | 54 | test "creates crop operation with default left and top", %{image: image, config: config} do 55 | params = %{ 56 | "width" => "100", 57 | "height" => "200" 58 | } 59 | 60 | {:ok, operation} = Crop.new(image, params, config) 61 | 62 | assert %Crop{} = operation 63 | assert operation.image == image 64 | assert operation.width == 100 65 | assert operation.height == 200 66 | assert operation.left == 0 67 | assert operation.top == 0 68 | end 69 | 70 | test "creates crop operation with integer parameters", %{image: image, config: config} do 71 | params = %{ 72 | "width" => 150, 73 | "height" => 250, 74 | "left" => 15, 75 | "top" => 25 76 | } 77 | 78 | {:ok, operation} = Crop.new(image, params, config) 79 | 80 | assert %Crop{} = operation 81 | assert operation.image == image 82 | assert operation.width == 150 83 | assert operation.height == 250 84 | assert operation.left == 15 85 | assert operation.top == 25 86 | end 87 | 88 | test "creates crop operation with only width and height", %{image: image, config: config} do 89 | params = %{ 90 | "width" => "300", 91 | "height" => "400" 92 | } 93 | 94 | {:ok, operation} = Crop.new(image, params, config) 95 | 96 | assert %Crop{} = operation 97 | assert operation.image == image 98 | assert operation.width == 300 99 | assert operation.height == 400 100 | assert operation.left == 0 101 | assert operation.top == 0 102 | end 103 | 104 | test "creates crop operation when width is missing (uses nil)", %{image: image, config: config} do 105 | params = %{ 106 | "height" => "200", 107 | "left" => "10", 108 | "top" => "20" 109 | } 110 | 111 | {:ok, operation} = Crop.new(image, params, config) 112 | 113 | assert %Crop{} = operation 114 | assert operation.image == image 115 | assert operation.width == nil 116 | assert operation.height == 200 117 | assert operation.left == 10 118 | assert operation.top == 20 119 | end 120 | 121 | test "creates crop operation when height is missing (uses nil)", %{image: image, config: config} do 122 | params = %{ 123 | "width" => "100", 124 | "left" => "10", 125 | "top" => "20" 126 | } 127 | 128 | {:ok, operation} = Crop.new(image, params, config) 129 | 130 | assert %Crop{} = operation 131 | assert operation.image == image 132 | assert operation.width == 100 133 | assert operation.height == nil 134 | assert operation.left == 10 135 | assert operation.top == 20 136 | end 137 | 138 | test "returns error when width is invalid", %{image: image, config: config} do 139 | params = %{ 140 | "width" => "invalid", 141 | "height" => "200", 142 | "left" => "10", 143 | "top" => "20" 144 | } 145 | 146 | result = Crop.new(image, params, config) 147 | 148 | assert {:error, :bad_request} = result 149 | end 150 | 151 | test "returns error when height is invalid", %{image: image, config: config} do 152 | params = %{ 153 | "width" => "100", 154 | "height" => "invalid", 155 | "left" => "10", 156 | "top" => "20" 157 | } 158 | 159 | result = Crop.new(image, params, config) 160 | 161 | assert {:error, :bad_request} = result 162 | end 163 | 164 | test "returns error when left is invalid", %{image: image, config: config} do 165 | params = %{ 166 | "width" => "100", 167 | "height" => "200", 168 | "left" => "invalid", 169 | "top" => "20" 170 | } 171 | 172 | result = Crop.new(image, params, config) 173 | 174 | assert {:error, :bad_request} = result 175 | end 176 | 177 | test "returns error when top is invalid", %{image: image, config: config} do 178 | params = %{ 179 | "width" => "100", 180 | "height" => "200", 181 | "left" => "10", 182 | "top" => "invalid" 183 | } 184 | 185 | result = Crop.new(image, params, config) 186 | 187 | assert {:error, :bad_request} = result 188 | end 189 | end 190 | 191 | describe "PlugImageProcessing.Operation implementation" do 192 | test "valid?/1 returns true when all required parameters are present", %{image: image, config: config} do 193 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "200", "left" => "10", "top" => "20"}, config) 194 | 195 | assert PlugImageProcessing.Operation.valid?(operation) == true 196 | end 197 | 198 | test "valid?/1 returns error when width is missing", %{image: image, config: config} do 199 | {:ok, operation} = Crop.new(image, %{"height" => "200", "left" => "10", "top" => "20"}, config) 200 | 201 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_arguments} 202 | end 203 | 204 | test "valid?/1 returns error when height is missing", %{image: image, config: config} do 205 | {:ok, operation} = Crop.new(image, %{"width" => "100", "left" => "10", "top" => "20"}, config) 206 | 207 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_arguments} 208 | end 209 | 210 | test "valid?/1 returns error when top is missing", %{image: image, config: config} do 211 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "200", "left" => "10"}, config) 212 | operation = %{operation | top: nil} 213 | 214 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_arguments} 215 | end 216 | 217 | test "valid?/1 returns error when left is missing", %{image: image, config: config} do 218 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "200", "top" => "20"}, config) 219 | operation = %{operation | left: nil} 220 | 221 | assert PlugImageProcessing.Operation.valid?(operation) == {:error, :missing_arguments} 222 | end 223 | 224 | test "process/2 crops image with extract_area", %{image: image, config: config} do 225 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "150", "left" => "10", "top" => "20"}, config) 226 | 227 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 228 | 229 | assert %Image{} = result_image 230 | assert Image.width(result_image) == 100 231 | assert Image.height(result_image) == 150 232 | end 233 | 234 | test "process/2 crops image with smart gravity", %{image: image, config: config} do 235 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "100", "left" => "0", "top" => "0", "gravity" => "smart"}, config) 236 | 237 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 238 | 239 | assert %Image{} = result_image 240 | assert Image.width(result_image) == 100 241 | assert Image.height(result_image) == 100 242 | end 243 | 244 | test "process/2 crops image from center area", %{image: image, config: config} do 245 | {:ok, operation} = Crop.new(image, %{"width" => "50", "height" => "50", "left" => "25", "top" => "25"}, config) 246 | 247 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 248 | 249 | assert %Image{} = result_image 250 | assert Image.width(result_image) == 50 251 | assert Image.height(result_image) == 50 252 | end 253 | 254 | test "process/2 crops image from top-left corner", %{image: image, config: config} do 255 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "100", "left" => "0", "top" => "0"}, config) 256 | 257 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, config) 258 | 259 | assert %Image{} = result_image 260 | assert Image.width(result_image) == 100 261 | assert Image.height(result_image) == 100 262 | end 263 | 264 | test "process/2 ignores config parameter", %{image: image} do 265 | {:ok, operation} = Crop.new(image, %{"width" => "100", "height" => "100", "left" => "10", "top" => "10"}, %Config{path: "/different"}) 266 | different_config = %Config{path: "/another", http_client_timeout: 5000} 267 | 268 | {:ok, result_image} = PlugImageProcessing.Operation.process(operation, different_config) 269 | 270 | assert %Image{} = result_image 271 | assert Image.width(result_image) == 100 272 | assert Image.height(result_image) == 100 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /test/plug_image_processing/sources/url_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.Sources.URLTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PlugImageProcessing.Config 5 | alias PlugImageProcessing.Source 6 | alias PlugImageProcessing.Sources.URL 7 | alias Vix.Vips.Image 8 | 9 | defmodule MockHTTPClient do 10 | @moduledoc false 11 | @behaviour PlugImageProcessing.Sources.HTTPClient 12 | 13 | @image File.read!("test/support/image.jpg") 14 | @gif_image File.read!("test/support/image.gif") 15 | @svg_image File.read!("test/support/image.svg") 16 | 17 | def get("http://example.com/image.jpg", _), do: {:ok, @image, [{"Content-Type", "image/jpeg"}]} 18 | def get("http://example.com/image.gif", _), do: {:ok, @gif_image, [{"Content-Type", "image/gif"}]} 19 | def get("http://example.com/image.svg", _), do: {:ok, @svg_image, [{"Content-Type", "image/svg+xml"}]} 20 | def get("http://example.com/image.webp", _), do: {:ok, @image, [{"Content-Type", "image/webp"}]} 21 | def get("http://example.com/image.png", _), do: {:ok, @image, [{"Content-Type", "image/png"}]} 22 | def get("http://example.com/image", _), do: {:ok, @image, [{"Content-Type", "image/jpeg"}]} 23 | def get("http://example.com/no-content-type.jpg", _), do: {:ok, @image, []} 24 | def get("http://example.com/no-content-type", _), do: {:ok, @image, []} 25 | def get("http://example.com/wrong-type.pdf", _), do: {:ok, "pdf data", [{"Content-Type", "application/pdf"}]} 26 | def get("http://example.com/404", _), do: {:http_error, 404} 27 | 28 | def get("http://example.com/timeout", _) do 29 | Process.sleep(100) 30 | {:ok, @image, [{"Content-Type", "image/jpeg"}]} 31 | end 32 | 33 | def get("http://example.com/exit", _), do: exit(:boom) 34 | end 35 | 36 | defmodule MockHTTPClientCache do 37 | @moduledoc false 38 | @behaviour PlugImageProcessing.Sources.HTTPClientCache 39 | 40 | def invalid_source?(%{uri: %{path: "/invalid"}}), do: true 41 | def invalid_source?(_), do: false 42 | 43 | def fetch_source(%{uri: %{path: "/cached"}}), do: {:ok, File.read!("test/support/image.jpg"), [{"Content-Type", "image/jpeg"}]} 44 | def fetch_source(_), do: nil 45 | 46 | def put_source(_, _), do: :ok 47 | end 48 | 49 | describe "cast/2" do 50 | test "creates URL source with valid URL" do 51 | params = %{"url" => "https://example.com/image.jpg"} 52 | source = %URL{} 53 | 54 | result = Source.cast(source, params) 55 | 56 | assert %URL{} = result 57 | assert result.uri.host == "example.com" 58 | assert result.uri.path == "/image.jpg" 59 | assert result.params == params 60 | end 61 | 62 | test "creates URL source with encoded URL" do 63 | params = %{"url" => "https%3A%2F%2Fexample.com%2Fimage.jpg"} 64 | source = %URL{} 65 | 66 | result = Source.cast(source, params) 67 | 68 | assert %URL{} = result 69 | assert result.uri.host == "example.com" 70 | assert result.uri.path == "/image.jpg" 71 | end 72 | 73 | test "creates URL source with query parameters in URL" do 74 | params = %{"url" => "https://example.com/image.jpg?foo=bar&baz=qux"} 75 | source = %URL{} 76 | 77 | result = Source.cast(source, params) 78 | 79 | assert %URL{} = result 80 | assert result.uri.host == "example.com" 81 | assert result.uri.path == "/image.jpg" 82 | assert result.uri.query == "foo=bar&baz=qux" 83 | end 84 | 85 | test "returns false when URL is missing" do 86 | params = %{} 87 | source = %URL{} 88 | 89 | result = Source.cast(source, params) 90 | 91 | assert result == false 92 | end 93 | 94 | test "returns false when URL is nil" do 95 | params = %{"url" => nil} 96 | source = %URL{} 97 | 98 | result = Source.cast(source, params) 99 | 100 | assert result == false 101 | end 102 | 103 | test "returns false when URL has no host" do 104 | params = %{"url" => "/path/to/image.jpg"} 105 | source = %URL{} 106 | 107 | result = Source.cast(source, params) 108 | 109 | assert result == false 110 | end 111 | 112 | test "returns false when URL is invalid" do 113 | params = %{"url" => "not a url"} 114 | source = %URL{} 115 | 116 | result = Source.cast(source, params) 117 | 118 | assert result == false 119 | end 120 | end 121 | 122 | describe "fetch_body/5" do 123 | test "fetches body successfully with valid image type from header" do 124 | source = %URL{ 125 | uri: URI.parse("http://example.com/image"), 126 | params: %{} 127 | } 128 | 129 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 130 | 131 | assert {:ok, _, "image/jpg", ".jpg"} = result 132 | end 133 | 134 | test "fetches body with type parameter override" do 135 | source = %URL{ 136 | uri: URI.parse("http://example.com/image.jpg"), 137 | params: %{"type" => "png"} 138 | } 139 | 140 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 141 | 142 | assert {:ok, _, "image/png", ".png"} = result 143 | end 144 | 145 | test "fetches body with quality parameter" do 146 | source = %URL{ 147 | uri: URI.parse("http://example.com/image.jpg"), 148 | params: %{"quality" => "90"} 149 | } 150 | 151 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 152 | 153 | assert {:ok, _, "image/jpg", ".jpg[Q=90]"} = result 154 | end 155 | 156 | test "fetches body with stripmeta parameter" do 157 | source = %URL{ 158 | uri: URI.parse("http://example.com/image.jpg"), 159 | params: %{"stripmeta" => "true"} 160 | } 161 | 162 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 163 | 164 | assert {:ok, _, "image/jpg", ".jpg[strip=true]"} = result 165 | end 166 | 167 | test "fetches body with both quality and stripmeta parameters" do 168 | source = %URL{ 169 | uri: URI.parse("http://example.com/image.jpg"), 170 | params: %{"quality" => "85", "stripmeta" => "true"} 171 | } 172 | 173 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 174 | 175 | assert {:ok, _, "image/jpg", ".jpg[Q=85,strip=true]"} = result 176 | end 177 | 178 | test "fetches GIF with special handling" do 179 | source = %URL{ 180 | uri: URI.parse("http://example.com/image.gif"), 181 | params: %{} 182 | } 183 | 184 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 185 | 186 | assert {:ok, _, "image/gif", ".gif"} = result 187 | end 188 | 189 | test "fetches GIF with stripmeta" do 190 | source = %URL{ 191 | uri: URI.parse("http://example.com/image.gif"), 192 | params: %{"stripmeta" => "true"} 193 | } 194 | 195 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 196 | 197 | assert {:ok, _, "image/gif", ".gif[strip=true]"} = result 198 | end 199 | 200 | test "converts SVG to PNG" do 201 | source = %URL{ 202 | uri: URI.parse("http://example.com/image.svg"), 203 | params: %{} 204 | } 205 | 206 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 207 | 208 | assert {:ok, _, "image/png", ".png"} = result 209 | end 210 | 211 | test "converts SVG to PNG with stripmeta" do 212 | source = %URL{ 213 | uri: URI.parse("http://example.com/image.svg"), 214 | params: %{"stripmeta" => "true"} 215 | } 216 | 217 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 218 | 219 | assert {:ok, _, "image/png", ".png[strip=true]"} = result 220 | end 221 | 222 | test "handles webp format" do 223 | source = %URL{ 224 | uri: URI.parse("http://example.com/image.webp"), 225 | params: %{} 226 | } 227 | 228 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 229 | 230 | assert {:ok, _, "image/webp", ".webp"} = result 231 | end 232 | 233 | test "handles webp with quality" do 234 | source = %URL{ 235 | uri: URI.parse("http://example.com/image.webp"), 236 | params: %{"quality" => "80"} 237 | } 238 | 239 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 240 | 241 | assert {:ok, _, "image/webp", ".webp[Q=80]"} = result 242 | end 243 | 244 | test "handles png format" do 245 | source = %URL{ 246 | uri: URI.parse("http://example.com/image.png"), 247 | params: %{} 248 | } 249 | 250 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 251 | 252 | assert {:ok, _, "image/png", ".png"} = result 253 | end 254 | 255 | test "falls back to file extension when Content-Type is missing" do 256 | source = %URL{ 257 | uri: URI.parse("http://example.com/no-content-type.jpg"), 258 | params: %{} 259 | } 260 | 261 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 262 | 263 | assert {:ok, _, "image/jpg", ".jpg"} = result 264 | end 265 | 266 | test "returns error for invalid file type" do 267 | source = %URL{ 268 | uri: URI.parse("http://example.com/wrong-type.pdf"), 269 | params: %{} 270 | } 271 | 272 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 273 | 274 | assert {:file_type_error, "Invalid file type: pdf"} = result 275 | end 276 | 277 | test "returns error when no file type can be determined" do 278 | source = %URL{ 279 | uri: URI.parse("http://example.com/no-content-type"), 280 | params: %{} 281 | } 282 | 283 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 284 | 285 | assert {:file_type_error, "Invalid file type: "} = result 286 | end 287 | 288 | test "returns cached error when source is invalid" do 289 | source = %URL{ 290 | uri: URI.parse("http://example.com/invalid"), 291 | params: %{} 292 | } 293 | 294 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 295 | 296 | assert {:cached_error, "http://example.com/invalid"} = result 297 | end 298 | 299 | test "returns cached source when available" do 300 | source = %URL{ 301 | uri: URI.parse("http://example.com/cached"), 302 | params: %{} 303 | } 304 | 305 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 306 | 307 | assert {:ok, _, "image/jpg", ".jpg"} = result 308 | end 309 | 310 | test "handles HTTP timeout" do 311 | source = %URL{ 312 | uri: URI.parse("http://example.com/timeout"), 313 | params: %{} 314 | } 315 | 316 | result = URL.fetch_body(source, 10, 10_000_000, MockHTTPClient, MockHTTPClientCache) 317 | 318 | assert {:http_timeout, "Timeout (10ms) on http://example.com/timeout"} = result 319 | end 320 | 321 | test "handles HTTP error response" do 322 | source = %URL{ 323 | uri: URI.parse("http://example.com/404"), 324 | params: %{} 325 | } 326 | 327 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 328 | 329 | assert {:http_error, 404} = result 330 | end 331 | 332 | test "handles type parameter with invalid value falls back to content-type" do 333 | source = %URL{ 334 | uri: URI.parse("http://example.com/image.jpg"), 335 | params: %{"type" => "invalid"} 336 | } 337 | 338 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 339 | 340 | # Falls back to Content-Type header 341 | assert {:ok, _, "image/jpg", ".jpg"} = result 342 | end 343 | 344 | test "handles type parameter for gif" do 345 | source = %URL{ 346 | uri: URI.parse("http://example.com/image.png"), 347 | params: %{"type" => "gif"} 348 | } 349 | 350 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 351 | 352 | assert {:ok, _, "image/gif", ".gif"} = result 353 | end 354 | 355 | test "handles type parameter for svg converts to png" do 356 | source = %URL{ 357 | uri: URI.parse("http://example.com/image.jpg"), 358 | params: %{"type" => "svg"} 359 | } 360 | 361 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 362 | 363 | assert {:ok, _, "image/png", ".png"} = result 364 | end 365 | 366 | test "handles stripmeta with false value" do 367 | source = %URL{ 368 | uri: URI.parse("http://example.com/image.jpg"), 369 | params: %{"stripmeta" => "false"} 370 | } 371 | 372 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 373 | 374 | assert {:ok, _, "image/jpg", ".jpg[strip=false]"} = result 375 | end 376 | 377 | # This test exposes a bug - invalid quality causes a crash 378 | # test "handles invalid quality value" do 379 | # source = %URL{ 380 | # uri: URI.parse("http://example.com/image.jpg"), 381 | # params: %{"quality" => "invalid"} 382 | # } 383 | # 384 | # result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 385 | # 386 | # # Currently this causes a Protocol.UndefinedError crash 387 | # # assert {:ok, _, "image/jpg", ".jpg"} = result 388 | # end 389 | 390 | test "handles quality value of nil" do 391 | source = %URL{ 392 | uri: URI.parse("http://example.com/image.jpg"), 393 | params: %{"quality" => nil} 394 | } 395 | 396 | result = URL.fetch_body(source, 5000, 10_000_000, MockHTTPClient, MockHTTPClientCache) 397 | 398 | assert {:ok, _, "image/jpg", ".jpg"} = result 399 | end 400 | end 401 | 402 | describe "get_image/3" do 403 | setup do 404 | config = %Config{ 405 | path: "/imageproxy", 406 | http_client_timeout: 5000, 407 | http_client_max_length: 10_000_000, 408 | http_client: MockHTTPClient, 409 | http_client_cache: MockHTTPClientCache, 410 | source_url_redirect_operations: [] 411 | } 412 | 413 | {:ok, config: config} 414 | end 415 | 416 | test "returns redirect when operation is in redirect list", %{config: config} do 417 | source = %URL{ 418 | uri: URI.parse("https://example.com/image.jpg"), 419 | params: %{} 420 | } 421 | 422 | config = %{config | source_url_redirect_operations: ["resize"]} 423 | 424 | result = Source.get_image(source, "resize", config) 425 | 426 | assert {:redirect, "https://example.com/image.jpg"} = result 427 | end 428 | 429 | test "returns redirect for multiple operations", %{config: config} do 430 | source = %URL{ 431 | uri: URI.parse("https://example.com/image.jpg"), 432 | params: %{} 433 | } 434 | 435 | config = %{config | source_url_redirect_operations: ["resize", "crop", "info"]} 436 | 437 | assert {:redirect, "https://example.com/image.jpg"} = Source.get_image(source, "resize", config) 438 | assert {:redirect, "https://example.com/image.jpg"} = Source.get_image(source, "crop", config) 439 | assert {:redirect, "https://example.com/image.jpg"} = Source.get_image(source, "info", config) 440 | end 441 | 442 | test "fetches and processes image successfully", %{config: config} do 443 | source = %URL{ 444 | uri: URI.parse("http://example.com/image.jpg"), 445 | params: %{} 446 | } 447 | 448 | result = Source.get_image(source, "resize", config) 449 | 450 | assert {:ok, image, "image/jpg", ".jpg"} = result 451 | assert %Image{} = image 452 | end 453 | 454 | test "fetches and processes GIF with special buffer options", %{config: config} do 455 | source = %URL{ 456 | uri: URI.parse("http://example.com/image.gif"), 457 | params: %{} 458 | } 459 | 460 | result = Source.get_image(source, "resize", config) 461 | 462 | assert {:ok, image, "image/gif", ".gif"} = result 463 | assert %Image{} = image 464 | end 465 | 466 | test "fetches and processes SVG converted to PNG", %{config: config} do 467 | source = %URL{ 468 | uri: URI.parse("http://example.com/image.svg"), 469 | params: %{} 470 | } 471 | 472 | result = Source.get_image(source, "resize", config) 473 | 474 | assert {:ok, image, "image/png", ".png"} = result 475 | assert %Image{} = image 476 | end 477 | 478 | test "handles timeout error", %{config: config} do 479 | source = %URL{ 480 | uri: URI.parse("http://example.com/timeout"), 481 | params: %{} 482 | } 483 | 484 | config = %{config | http_client_timeout: 10} 485 | 486 | result = Source.get_image(source, "resize", config) 487 | 488 | assert {:error, :timeout} = result 489 | end 490 | 491 | test "handles cached error", %{config: config} do 492 | source = %URL{ 493 | uri: URI.parse("http://example.com/invalid"), 494 | params: %{} 495 | } 496 | 497 | result = Source.get_image(source, "resize", config) 498 | 499 | assert {:error, :invalid_file} = result 500 | end 501 | 502 | test "handles invalid file type error", %{config: config} do 503 | source = %URL{ 504 | uri: URI.parse("http://example.com/wrong-type.pdf"), 505 | params: %{} 506 | } 507 | 508 | result = Source.get_image(source, "resize", config) 509 | 510 | assert {:error, :invalid_file_type} = result 511 | end 512 | 513 | test "handles HTTP error", %{config: config} do 514 | source = %URL{ 515 | uri: URI.parse("http://example.com/404"), 516 | params: %{} 517 | } 518 | 519 | result = Source.get_image(source, "resize", config) 520 | 521 | assert {:error, :invalid_file} = result 522 | end 523 | 524 | test "processes image with quality parameter", %{config: config} do 525 | source = %URL{ 526 | uri: URI.parse("http://example.com/image.jpg"), 527 | params: %{"quality" => "90"} 528 | } 529 | 530 | result = Source.get_image(source, "resize", config) 531 | 532 | assert {:ok, image, "image/jpg", ".jpg[Q=90]"} = result 533 | assert %Image{} = image 534 | end 535 | 536 | test "processes image with stripmeta parameter", %{config: config} do 537 | source = %URL{ 538 | uri: URI.parse("http://example.com/image.jpg"), 539 | params: %{"stripmeta" => "true"} 540 | } 541 | 542 | result = Source.get_image(source, "resize", config) 543 | 544 | assert {:ok, image, "image/jpg", ".jpg[strip=true]"} = result 545 | assert %Image{} = image 546 | end 547 | end 548 | end 549 | -------------------------------------------------------------------------------- /test/plug_image_processing/web_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugImageProcessing.WebTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Conn 5 | import Plug.Test 6 | 7 | alias PlugImageProcessing.Web 8 | alias Vix.Vips.Image 9 | 10 | defmodule HTTPMock do 11 | @moduledoc false 12 | @behaviour PlugImageProcessing.Sources.HTTPClient 13 | 14 | @image File.read!("test/support/image.jpg") 15 | @gif_image File.read!("test/support/image.gif") 16 | @svg_image File.read!("test/support/image.svg") 17 | 18 | def get("http://example.org/valid.jpg", _), do: {:ok, @image, [{"Content-type", "image/jpg"}]} 19 | def get("http://example.org/valid.gif", _), do: {:ok, @gif_image, [{"Content-type", "image/gif"}]} 20 | def get("http://example.org/valid.svg", _), do: {:ok, @svg_image, [{"Content-type", "image/svg"}]} 21 | def get("http://example.org/valid-xml.svg", _), do: {:ok, @svg_image, [{"Content-type", "image/svg+xml"}]} 22 | def get("http://example.org/retry.jpg", _), do: {:ok, @image, [{"Content-type", "image/jpg"}]} 23 | def get("http://example.org/404.jpg", _), do: {:error, "404 Not found"} 24 | def get("http://example.org/index.html", _), do: {:ok, "", [{"Content-type", "text/html"}]} 25 | 26 | def get("http://example.org/timeout.jpg", _) do 27 | Process.sleep(1000) 28 | {:ok, @image, [{"Content-type", "image/jpg"}]} 29 | end 30 | end 31 | 32 | setup do 33 | config = [ 34 | path: "/imageproxy", 35 | http_client: HTTPMock 36 | ] 37 | 38 | {:ok, config: config} 39 | end 40 | 41 | defp conn_to_image(conn) do 42 | Image.new_from_buffer(conn.resp_body) 43 | end 44 | 45 | describe "error handling" do 46 | test "source URL timeout", %{config: config} do 47 | config = Keyword.put(config, :http_client_timeout, 1) 48 | 49 | plug_opts = Web.init(config) 50 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/timeout.jpg"}) 51 | conn = Web.call(conn, plug_opts) 52 | 53 | assert conn.resp_body === "Bad request: :timeout" 54 | assert conn.status === 400 55 | end 56 | 57 | test "source URL invalid type", %{config: config} do 58 | plug_opts = Web.init(config) 59 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/index.html"}) 60 | conn = Web.call(conn, plug_opts) 61 | 62 | assert conn.resp_body === "Bad request: :invalid_file_type" 63 | assert conn.status === 400 64 | end 65 | 66 | test "source URL 404", %{config: config} do 67 | plug_opts = Web.init(config) 68 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg"}) 69 | conn = Web.call(conn, plug_opts) 70 | 71 | assert conn.resp_body === "Bad request: :invalid_file" 72 | assert conn.status === 400 73 | end 74 | 75 | test "source URL 404 with retry onerror", %{config: config} do 76 | on_error = fn conn -> 77 | params = %{ 78 | "url" => "http://example.org/retry.jpg", 79 | "width" => 10 80 | } 81 | 82 | {:retry, %{conn | params: params}} 83 | end 84 | 85 | config = Keyword.put(config, :onerror, %{"test" => on_error}) 86 | plug_opts = Web.init(config) 87 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg", onerror: "test"}) 88 | conn = Web.call(conn, plug_opts) 89 | {:ok, image} = conn_to_image(conn) 90 | 91 | assert get_resp_header(conn, "cache-control") === ["private, no-cache, no-store, must-revalidate"] 92 | assert Image.width(image) === 10 93 | end 94 | 95 | test "source URL 404 with halt onerror", %{config: config} do 96 | on_error = fn conn -> 97 | conn = send_resp(conn, 500, "Oops") 98 | {:halt, conn} 99 | end 100 | 101 | config = Keyword.put(config, :onerror, %{"test" => on_error}) 102 | plug_opts = Web.init(config) 103 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg", onerror: "test"}) 104 | conn = Web.call(conn, plug_opts) 105 | 106 | assert get_resp_header(conn, "cache-control") === ["private, no-cache, no-store, must-revalidate"] 107 | assert conn.status === 500 108 | assert conn.resp_body === "Oops" 109 | end 110 | 111 | test "source URL 404 with conn update onerror", %{config: config} do 112 | on_error = fn conn -> 113 | put_resp_header(conn, "x-image-error", "Test") 114 | end 115 | 116 | config = Keyword.put(config, :onerror, %{"test" => on_error}) 117 | plug_opts = Web.init(config) 118 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg", onerror: "test"}) 119 | conn = Web.call(conn, plug_opts) 120 | 121 | assert get_resp_header(conn, "x-image-error") === ["Test"] 122 | assert conn.status === 400 123 | assert conn.resp_body === "Bad request: :invalid_file" 124 | end 125 | end 126 | 127 | describe "operations" do 128 | test "resize", %{config: config} do 129 | plug_opts = Web.init(config) 130 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.jpg"}) 131 | conn = Web.call(conn, plug_opts) 132 | {:ok, image} = conn_to_image(conn) 133 | 134 | assert Image.width(image) === 20 135 | end 136 | 137 | test "resize svg", %{config: config} do 138 | plug_opts = Web.init(config) 139 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.svg"}) 140 | conn = Web.call(conn, plug_opts) 141 | {:ok, image} = conn_to_image(conn) 142 | 143 | assert Image.width(image) === 20 144 | end 145 | 146 | test "echo gif", %{config: config} do 147 | plug_opts = Web.init(config) 148 | conn = conn(:get, "/imageproxy", %{url: "http://example.org/valid.gif"}) 149 | conn = Web.call(conn, plug_opts) 150 | 151 | assert conn.status === 200 152 | end 153 | 154 | test "echo svg", %{config: config} do 155 | plug_opts = Web.init(config) 156 | conn = conn(:get, "/imageproxy", %{url: "http://example.org/valid.svg"}) 157 | conn = Web.call(conn, plug_opts) 158 | 159 | assert conn.status === 200 160 | end 161 | 162 | test "echo svg+xml", %{config: config} do 163 | plug_opts = Web.init(config) 164 | conn = conn(:get, "/imageproxy", %{url: "http://example.org/valid-xml.svg"}) 165 | conn = Web.call(conn, plug_opts) 166 | 167 | assert conn.status === 200 168 | end 169 | 170 | test "echo redirect gif", %{config: config} do 171 | config = Keyword.put(config, :source_url_redirect_operations, [""]) 172 | plug_opts = Web.init(config) 173 | conn = conn(:get, "/imageproxy", %{url: "http://example.org/valid.gif"}) 174 | conn = Web.call(conn, plug_opts) 175 | 176 | assert get_resp_header(conn, "location") === ["http://example.org/valid.gif"] 177 | assert conn.status === 301 178 | end 179 | 180 | test "crop", %{config: config} do 181 | plug_opts = Web.init(config) 182 | conn = conn(:get, "/imageproxy/crop", %{width: 20, height: 50, url: "http://example.org/valid.jpg"}) 183 | conn = Web.call(conn, plug_opts) 184 | {:ok, image} = conn_to_image(conn) 185 | 186 | assert Image.width(image) === 20 187 | assert Image.height(image) === 50 188 | end 189 | 190 | test "info", %{config: config} do 191 | plug_opts = Web.init(config) 192 | conn = conn(:get, "/imageproxy/info", %{url: "http://example.org/valid.jpg"}) 193 | conn = Web.call(conn, plug_opts) 194 | 195 | image_metadata = Jason.decode!(conn.resp_body) 196 | 197 | assert image_metadata["width"] === 512 198 | assert image_metadata["height"] === 512 199 | assert image_metadata["has_alpha"] === false 200 | assert image_metadata["channels"] === 3 201 | end 202 | 203 | test "pipeline", %{config: config} do 204 | plug_opts = Web.init(config) 205 | conn = conn(:get, "/imageproxy/pipeline", %{operations: Jason.encode!([%{operation: "crop", params: %{width: 20, height: 50}}]), url: "http://example.org/valid.jpg"}) 206 | conn = Web.call(conn, plug_opts) 207 | 208 | {:ok, image} = conn_to_image(conn) 209 | 210 | assert Image.width(image) === 20 211 | assert Image.height(image) === 50 212 | end 213 | 214 | test "pipeline info", %{config: config} do 215 | plug_opts = Web.init(config) 216 | 217 | conn = 218 | conn(:get, "/imageproxy/pipeline", %{ 219 | operations: Jason.encode!([%{operation: "crop", params: %{width: 20, height: 50}}, %{operation: "info"}]), 220 | url: "http://example.org/valid.jpg" 221 | }) 222 | 223 | conn = Web.call(conn, plug_opts) 224 | 225 | assert conn.status === 400 226 | assert conn.resp_body === "Bad request: :invalid_operation" 227 | end 228 | end 229 | 230 | describe "config handling" do 231 | test "config as module and function" do 232 | defmodule ConfigModule do 233 | @moduledoc false 234 | def get_config do 235 | [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 236 | end 237 | end 238 | 239 | plug_opts = Web.init({ConfigModule, :get_config}) 240 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.jpg"}) 241 | conn = Web.call(conn, plug_opts) 242 | {:ok, image} = conn_to_image(conn) 243 | 244 | assert Image.width(image) === 20 245 | end 246 | 247 | test "config as module, function and args" do 248 | defmodule ConfigModuleWithArgs do 249 | @moduledoc false 250 | def get_config(path, client) do 251 | [path: path, http_client: client] 252 | end 253 | end 254 | 255 | plug_opts = Web.init({ConfigModuleWithArgs, :get_config, ["/imageproxy", PlugImageProcessing.WebTest.HTTPMock]}) 256 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.jpg"}) 257 | conn = Web.call(conn, plug_opts) 258 | {:ok, image} = conn_to_image(conn) 259 | 260 | assert Image.width(image) === 20 261 | end 262 | 263 | test "config as zero-arity function" do 264 | config_fn = fn -> [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] end 265 | plug_opts = Web.init(config_fn) 266 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.jpg"}) 267 | conn = Web.call(conn, plug_opts) 268 | {:ok, image} = conn_to_image(conn) 269 | 270 | assert Image.width(image) === 20 271 | end 272 | 273 | test "invalid config raises error" do 274 | plug_opts = Web.init("invalid_config") 275 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.jpg"}) 276 | 277 | assert_raise ArgumentError, ~r/Invalid config/, fn -> 278 | Web.call(conn, plug_opts) 279 | end 280 | end 281 | end 282 | 283 | describe "path handling" do 284 | test "request outside config path" do 285 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 286 | plug_opts = Web.init(config) 287 | conn = conn(:get, "/other/path", %{}) 288 | conn = Web.call(conn, plug_opts) 289 | 290 | # Should pass through without processing 291 | assert conn.status === nil 292 | refute conn.halted 293 | end 294 | 295 | test "root path with trailing slash" do 296 | config = [path: "/imageproxy/", http_client: PlugImageProcessing.WebTest.HTTPMock] 297 | plug_opts = Web.init(config) 298 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/valid.jpg"}) 299 | conn = Web.call(conn, plug_opts) 300 | {:ok, image} = conn_to_image(conn) 301 | 302 | assert Image.width(image) === 20 303 | end 304 | 305 | test "operation with extra path segments" do 306 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 307 | plug_opts = Web.init(config) 308 | conn = conn(:get, "/imageproxy/resize/extra/segments", %{width: 20, url: "http://example.org/valid.jpg"}) 309 | conn = Web.call(conn, plug_opts) 310 | {:ok, image} = conn_to_image(conn) 311 | 312 | assert Image.width(image) === 20 313 | end 314 | end 315 | 316 | describe "redirect handling" do 317 | test "redirect with POST method" do 318 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, source_url_redirect_operations: ["resize"]] 319 | plug_opts = Web.init(config) 320 | conn = conn(:post, "/imageproxy/resize", %{url: "http://example.org/valid.jpg"}) 321 | conn = Web.call(conn, plug_opts) 322 | 323 | assert get_resp_header(conn, "location") === ["http://example.org/valid.jpg"] 324 | # temporary_redirect for POST 325 | assert conn.status === 307 326 | end 327 | 328 | test "redirect with HEAD method" do 329 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, source_url_redirect_operations: ["resize"]] 330 | plug_opts = Web.init(config) 331 | conn = conn(:head, "/imageproxy/resize", %{url: "http://example.org/valid.jpg"}) 332 | conn = Web.call(conn, plug_opts) 333 | 334 | assert get_resp_header(conn, "location") === ["http://example.org/valid.jpg"] 335 | # moved_permanently for HEAD 336 | assert conn.status === 301 337 | end 338 | 339 | test "redirect with PUT method" do 340 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, source_url_redirect_operations: ["resize"]] 341 | plug_opts = Web.init(config) 342 | conn = conn(:put, "/imageproxy/resize", %{url: "http://example.org/valid.jpg"}) 343 | conn = Web.call(conn, plug_opts) 344 | 345 | assert get_resp_header(conn, "location") === ["http://example.org/valid.jpg"] 346 | # temporary_redirect for PUT 347 | assert conn.status === 307 348 | end 349 | 350 | test "info operation with redirect" do 351 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, source_url_redirect_operations: ["info"]] 352 | plug_opts = Web.init(config) 353 | conn = conn(:get, "/imageproxy/info", %{url: "http://example.org/valid.jpg"}) 354 | conn = Web.call(conn, plug_opts) 355 | 356 | assert get_resp_header(conn, "location") === ["http://example.org/valid.jpg"] 357 | assert conn.status === 301 358 | end 359 | 360 | test "info operation with POST redirect" do 361 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, source_url_redirect_operations: ["info"]] 362 | plug_opts = Web.init(config) 363 | conn = conn(:post, "/imageproxy/info", %{url: "http://example.org/valid.jpg"}) 364 | conn = Web.call(conn, plug_opts) 365 | 366 | assert get_resp_header(conn, "location") === ["http://example.org/valid.jpg"] 367 | assert conn.status === 307 368 | end 369 | end 370 | 371 | describe "error handling without retry" do 372 | test "error without onerror handler" do 373 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 374 | plug_opts = Web.init(config) 375 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg"}) 376 | conn = Web.call(conn, plug_opts) 377 | 378 | assert conn.status === 400 379 | assert conn.resp_body === "Bad request: :invalid_file" 380 | assert get_resp_header(conn, "cache-control") === ["private, no-cache, no-store, must-revalidate"] 381 | end 382 | 383 | test "error with invalid onerror key" do 384 | on_error = fn conn -> 385 | {:retry, %{conn | params: %{"url" => "http://example.org/retry.jpg", "width" => 10}}} 386 | end 387 | 388 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, onerror: %{"valid" => on_error}] 389 | plug_opts = Web.init(config) 390 | # Use "invalid" key that doesn't exist in onerror map 391 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg", onerror: "invalid"}) 392 | conn = Web.call(conn, plug_opts) 393 | 394 | assert conn.status === 400 395 | assert conn.resp_body === "Bad request: :invalid_file" 396 | end 397 | 398 | test "error with nil onerror handler" do 399 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, onerror: %{"test" => nil}] 400 | plug_opts = Web.init(config) 401 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg", onerror: "test"}) 402 | conn = Web.call(conn, plug_opts) 403 | 404 | assert conn.status === 400 405 | assert conn.resp_body === "Bad request: :invalid_file" 406 | end 407 | 408 | test "retry that also fails" do 409 | on_error = fn conn -> 410 | # Retry with another failing URL 411 | {:retry, %{conn | params: %{"url" => "http://example.org/404.jpg", "width" => 10}}} 412 | end 413 | 414 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, onerror: %{"test" => on_error}] 415 | plug_opts = Web.init(config) 416 | conn = conn(:get, "/imageproxy/resize", %{width: 20, url: "http://example.org/404.jpg", onerror: "test"}) 417 | conn = Web.call(conn, plug_opts) 418 | 419 | # After retry fails, it should return the error 420 | assert conn.status === 400 421 | assert conn.resp_body === "Bad request: :invalid_file" 422 | assert get_resp_header(conn, "cache-control") === ["private, no-cache, no-store, must-revalidate"] 423 | end 424 | end 425 | 426 | describe "invalid operation handling" do 427 | test "invalid operation name" do 428 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 429 | plug_opts = Web.init(config) 430 | conn = conn(:get, "/imageproxy/invalid_op", %{url: "http://example.org/valid.jpg"}) 431 | conn = Web.call(conn, plug_opts) 432 | 433 | assert conn.status === 400 434 | assert conn.resp_body === "Bad request: :invalid_operation" 435 | end 436 | 437 | test "missing required params" do 438 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 439 | plug_opts = Web.init(config) 440 | # resize requires width 441 | conn = conn(:get, "/imageproxy/resize", %{url: "http://example.org/valid.jpg"}) 442 | conn = Web.call(conn, plug_opts) 443 | 444 | assert conn.status === 400 445 | assert conn.resp_body === "Bad request: :missing_width" 446 | end 447 | end 448 | 449 | describe "info operation error handling" do 450 | test "info with source error" do 451 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock] 452 | plug_opts = Web.init(config) 453 | conn = conn(:get, "/imageproxy/info", %{url: "http://example.org/404.jpg"}) 454 | conn = Web.call(conn, plug_opts) 455 | 456 | assert conn.status === 400 457 | assert conn.resp_body === "Bad request: :invalid_file" 458 | assert get_resp_header(conn, "cache-control") === ["private, no-cache, no-store, must-revalidate"] 459 | end 460 | 461 | test "info with onerror retry" do 462 | on_error = fn conn -> 463 | {:retry, %{conn | params: %{"url" => "http://example.org/valid.jpg"}}} 464 | end 465 | 466 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, onerror: %{"test" => on_error}] 467 | plug_opts = Web.init(config) 468 | conn = conn(:get, "/imageproxy/info", %{url: "http://example.org/404.jpg", onerror: "test"}) 469 | conn = Web.call(conn, plug_opts) 470 | 471 | image_metadata = Jason.decode!(conn.resp_body) 472 | assert image_metadata["width"] === 512 473 | assert image_metadata["height"] === 512 474 | end 475 | 476 | test "info with onerror halt" do 477 | on_error = fn conn -> 478 | conn = send_resp(conn, 503, "Service Unavailable") 479 | {:halt, conn} 480 | end 481 | 482 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, onerror: %{"test" => on_error}] 483 | plug_opts = Web.init(config) 484 | conn = conn(:get, "/imageproxy/info", %{url: "http://example.org/404.jpg", onerror: "test"}) 485 | conn = Web.call(conn, plug_opts) 486 | 487 | assert conn.status === 503 488 | assert conn.resp_body === "Service Unavailable" 489 | end 490 | 491 | test "info with onerror conn update" do 492 | on_error = fn conn -> 493 | put_resp_header(conn, "x-error", "info-failed") 494 | end 495 | 496 | config = [path: "/imageproxy", http_client: PlugImageProcessing.WebTest.HTTPMock, onerror: %{"test" => on_error}] 497 | plug_opts = Web.init(config) 498 | conn = conn(:get, "/imageproxy/info", %{url: "http://example.org/404.jpg", onerror: "test"}) 499 | conn = Web.call(conn, plug_opts) 500 | 501 | assert get_resp_header(conn, "x-error") === ["info-failed"] 502 | assert conn.status === 400 503 | assert conn.resp_body === "Bad request: :invalid_file" 504 | end 505 | end 506 | end 507 | --------------------------------------------------------------------------------