├── test ├── test_helper.exs └── imgproxy_test.exs ├── .formatter.exs ├── lib ├── mix │ └── tasks │ │ └── imgproxy.gen.secret.ex └── imgproxy.ex ├── .gitignore ├── LICENSE.md ├── config └── config.exs ├── CHANGELOG.md ├── mix.exs ├── .github └── workflows │ └── ci.yml ├── README.md ├── mix.lock └── .credo.exs /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/mix/tasks/imgproxy.gen.secret.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Imgproxy.Gen.Secret do 2 | @shortdoc "Generate a secret for use as salt / key" 3 | @moduledoc """ 4 | Generates a secret that could be used as a salt or key and prints it to the terminal. 5 | 6 | $ mix imgproxy.gen.secret 7 | 8 | """ 9 | use Mix.Task 10 | 11 | def run([]) do 12 | 64 13 | |> :crypto.strong_rand_bytes() 14 | |> Base.encode16(case: :lower) 15 | |> Mix.Shell.IO.info() 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | imgproxy-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 Brian Muller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # third-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :imgproxy, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:imgproxy, :key) 18 | # 19 | # You can also configure a third-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env()}.exs" 31 | 32 | config :imgproxy, 33 | prefix: "" 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Enhancements 11 | 12 | * Added support for unencoded source URLs (#8) 13 | 14 | ## v3.1.0 (2025-06-27) 15 | 16 | ### Deprecations 17 | 18 | * Removed support for Elixir before version 1.15 19 | 20 | ## v3.0.2 (2023-02-18) 21 | 22 | ### Enhancements 23 | 24 | * Added support for the [Info Endpoint](https://docs.imgproxy.net/usage/getting_info) (#6) 25 | 26 | ## v3.0.1 (2022-01-17) 27 | 28 | ### Fixed 29 | 30 | * Fixed issue producing a compile warning 31 | 32 | ## v3.0.0 (2021-09-15) 33 | 34 | ### Enhancements 35 | 36 | * Support for [advanced URL generation](https://docs.imgproxy.net/generating_the_url_advanced?id=generating-the-url-advanced) 37 | 38 | ### Hard-deprecations 39 | 40 | * No support for imgproxy before version 2.0.0 41 | * The `Imgproxy` library API has changed significantly and does not have any backward compatibility before version 3.0.0 42 | 43 | ## v2.0.0 (2020-05-24) 44 | 45 | ### Enhancements 46 | 47 | * Code now works with OTP 24 and the updated `:crypto` library 48 | 49 | ### Hard-deprecations 50 | 51 | * No OTP versions below 22 are supported. 52 | 53 | ## v1.0.0 (2020-05-23) 54 | 55 | ### Enhancements 56 | 57 | * Updated docs with specs to be more readable 58 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ImgProxy.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/bmuller/imgproxy" 5 | @version "3.1.0" 6 | 7 | def project do 8 | [ 9 | app: :imgproxy, 10 | aliases: aliases(), 11 | version: @version, 12 | elixir: "~> 1.15", 13 | start_permanent: Mix.env() == :prod, 14 | description: "imgproxy URL generator and helper functions", 15 | deps: deps(), 16 | package: package(), 17 | source_url: @source_url, 18 | docs: docs(), 19 | dialyzer: [plt_add_apps: [:mix]] 20 | ] 21 | end 22 | 23 | def cli do 24 | [preferred_envs: [test: :test, "ci.test": :test]] 25 | end 26 | 27 | defp docs do 28 | [ 29 | extras: [ 30 | "CHANGELOG.md": [], 31 | "LICENSE.md": [title: "License"], 32 | "README.md": [title: "Overview"] 33 | ], 34 | extra_section: "GUIDES", 35 | main: "readme", 36 | source_url: @source_url, 37 | source_ref: "v#{@version}", 38 | formatters: ["html"] 39 | ] 40 | end 41 | 42 | defp aliases do 43 | [ 44 | "ci.test": [ 45 | "format --check-formatted", 46 | "test", 47 | "credo" 48 | ] 49 | ] 50 | end 51 | 52 | def package do 53 | [ 54 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 55 | maintainers: ["Brian Muller"], 56 | licenses: ["MIT"], 57 | links: %{ 58 | "Changelog" => "https://hexdocs.pm/imgproxy/changelog.html", 59 | "GitHub" => @source_url, 60 | "imgproxy Site" => "https://imgproxy.net" 61 | } 62 | ] 63 | end 64 | 65 | def application do 66 | [ 67 | extra_applications: [:crypto, :logger] 68 | ] 69 | end 70 | 71 | defp deps do 72 | [ 73 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 74 | {:ex_doc, "~> 0.28", only: :dev, runtime: false}, 75 | {:dialyxir, "~> 1.4", only: :dev, runtime: false} 76 | ] 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: push 3 | 4 | env: 5 | LATEST_ELIXIR_VERSION: 1.18.x 6 | LATEST_OTP_VERSION: 28.x 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | include: 14 | - pair: 15 | otp: 28.x 16 | elixir: 1.19.x 17 | - pair: 18 | otp: 28.x 19 | elixir: 1.18.x 20 | - pair: 21 | otp: 27.x 22 | elixir: 1.18.x 23 | - pair: 24 | otp: 27.x 25 | elixir: 1.17.x 26 | - pair: 27 | otp: 26.x 28 | elixir: 1.17.x 29 | - pair: 30 | otp: 26.x 31 | elixir: 1.16.x 32 | - pair: 33 | otp: 25.x 34 | elixir: 1.15.x 35 | - pair: 36 | otp: 24.x 37 | elixir: 1.15.x 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: erlef/setup-beam@v1 42 | with: 43 | elixir-version: ${{ matrix.pair.elixir }} 44 | otp-version: ${{ matrix.pair.otp }} 45 | - run: mix deps.get 46 | - run: mix format --check-formatted 47 | - run: mix credo 48 | - run: mix test 49 | 50 | dialyzer: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: erlef/setup-beam@v1 55 | with: 56 | elixir-version: ${{ env.LATEST_ELIXIR_VERSION }} 57 | otp-version: ${{ env.LATEST_OTP_VERSION }} 58 | - uses: actions/cache@v4 59 | id: mix-cache # id to use in retrieve action 60 | with: 61 | path: | 62 | _build 63 | deps 64 | priv/plts 65 | key: dialyzer-cache-v0-${{matrix.elixir}}-${{matrix.otp}}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 66 | - if: steps.mix-cache.outputs.cache-hit != 'true' 67 | run: mix do deps.get, deps.compile 68 | - run: mix dialyzer 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Imgproxy 2 | 3 | [![Build Status](https://github.com/bmuller/imgproxy/actions/workflows/ci.yml/badge.svg)](https://github.com/bmuller/imgproxy/actions/workflows/ci.yml) 4 | [![Module Version](https://img.shields.io/hexpm/v/imgproxy.svg)](https://hex.pm/packages/imgproxy) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/imgproxy/) 6 | 7 | Imgproxy is an Elixir library that helps generate [imgproxy](https://github.com/DarthSim/imgproxy) URLs. Before using this library, you should have a running imgproxy server. 8 | 9 | **Note:** As of version `3.0`, OTP `>= 22.1` and `imgproxy >= 2.0.0` are required. 10 | 11 | ## Installation 12 | 13 | To install Imgproxy, just add an entry to your `mix.exs`: 14 | 15 | ``` elixir 16 | def deps do 17 | [ 18 | {:imgproxy, "~> 3.1"} 19 | ] 20 | end 21 | ``` 22 | 23 | (Check [Hex](https://hex.pm/packages/imgproxy) to make sure you're using an up-to-date version number.) 24 | 25 | ## Configuration 26 | 27 | In your `config/config.exs` you can set a few options: 28 | 29 | ``` elixir 30 | config :imgproxy, 31 | prefix: "https://imgcdn.example.com", 32 | key: "cdf104fc78b7d7f6f0158c253612f5dsecretsecret...", 33 | salt: "aad7034f611b7fc28c6d344f72ea19secretsecret..." 34 | ``` 35 | 36 | The `prefix` should be the location of the imgproxy server. `key` and `salt` are only necessary if you are using [URL signatures](https://docs.imgproxy.net/signing_the_url). To generate the key a key and salt, you can use: 37 | 38 | ``` bash 39 | $ mix imgproxy.gen.secret 40 | ``` 41 | 42 | You can use the output as your key or salt (ideally, just run the command twice, use the first output for your key and the second output for your salt). 43 | 44 | ## Usage 45 | 46 | Usage is simple - first generate an `Imgproxy` struct via `Imgproxy.new/1`, add any options you'd like, then convert to a string. 47 | 48 | Example: 49 | 50 | ```elixir 51 | # Generate URL for an image, using defaults 52 | Imgproxy.new("https://placekitten.com/200/300") |> to_string() 53 | 54 | # Resize to 123x321 55 | "https://placekitten.com/200/300" 56 | |> Imgproxy.new() 57 | |> Imgproxy.resize(123, 321, type: "fill") 58 | |> to_string() 59 | 60 | 61 | # Crop and return a jpg 62 | "https://placekitten.com/200/300" 63 | |> Imgproxy.new() 64 | |> Imgproxy.crop(100, 100) 65 | |> Imgproxy.set_extension("jpg") 66 | |> to_string() 67 | ``` 68 | 69 | ## Running Tests 70 | 71 | To run tests: 72 | 73 | ``` shell 74 | $ mix test 75 | ``` 76 | 77 | ## Reporting Issues 78 | 79 | Please report all issues [on github](https://github.com/bmuller/imgproxy/issues). 80 | 81 | ## Copyright and License 82 | 83 | Copyright (c) 2019 Brian Muller 84 | 85 | This work is free. You can redistribute it and/or modify it under the 86 | terms of the MIT License. See the [LICENSE.md](./LICENSE.md) file for more details. 87 | -------------------------------------------------------------------------------- /test/imgproxy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ImgproxyTest do 2 | use ExUnit.Case 3 | doctest Imgproxy 4 | 5 | @img_url "http://example.com/image.gif" 6 | @img_url_encoded Base.url_encode64(@img_url, padding: false) 7 | @prefix "https://imgcdn.example.com" 8 | 9 | setup_all do 10 | Application.put_env(:imgproxy, :prefix, @prefix) 11 | end 12 | 13 | describe "building unsigned urls should" do 14 | test "support no processing options" do 15 | result = @img_url |> Imgproxy.new() |> to_string() 16 | assert result == "#{@prefix}/insecure/#{@img_url_encoded}" 17 | end 18 | 19 | test "support the resize option with arguments" do 20 | result = 21 | @img_url 22 | |> Imgproxy.new() 23 | |> Imgproxy.resize(123, 456, type: "fill", enlarge: true) 24 | |> to_string() 25 | 26 | assert result == "#{@prefix}/insecure/rs:fill:123:456:true/#{@img_url_encoded}" 27 | end 28 | 29 | test "support multiple options" do 30 | result = 31 | @img_url 32 | |> Imgproxy.new() 33 | |> Imgproxy.resize(123, 456, type: "fill", enlarge: true) 34 | |> Imgproxy.set_gravity("sm") 35 | |> to_string() 36 | 37 | assert result == "#{@prefix}/insecure/g:sm/rs:fill:123:456:true/#{@img_url_encoded}" 38 | end 39 | 40 | test "support setting an extension" do 41 | result = @img_url |> Imgproxy.new() |> Imgproxy.set_extension("png") |> to_string() 42 | assert result == "#{@prefix}/insecure/#{@img_url_encoded}.png" 43 | 44 | # now try with an unnecessary dot 45 | result = @img_url |> Imgproxy.new() |> Imgproxy.set_extension(".jpg") |> to_string() 46 | assert result == "#{@prefix}/insecure/#{@img_url_encoded}.jpg" 47 | end 48 | end 49 | 50 | describe "building signed urls should" do 51 | setup do 52 | Application.put_env( 53 | :imgproxy, 54 | :key, 55 | "6b505d74dfaee5742f951abff4893ceb9e61b7a7dd52a462d6b2c641" 56 | ) 57 | 58 | Application.put_env(:imgproxy, :salt, "784b05d765951edaadc64130bf19750b0d") 59 | 60 | on_exit(fn -> 61 | Application.delete_env(:imgproxy, :key) 62 | Application.delete_env(:imgproxy, :salt) 63 | end) 64 | end 65 | 66 | test "support no processing options" do 67 | result = @img_url |> Imgproxy.new() |> to_string() 68 | signature = "F7xXm0-O-JVpBIz5Z9JvBGog19LgvTDT4y8dzIQ9H28" 69 | assert result == "#{@prefix}/#{signature}/#{@img_url_encoded}" 70 | end 71 | 72 | test "support the resize option with arguments" do 73 | result = 74 | @img_url 75 | |> Imgproxy.new() 76 | |> Imgproxy.resize(123, 456, type: "fill", enlarge: true) 77 | |> to_string() 78 | 79 | signature = "o0xH0LYlMU7-2lCm4HqeahdxX0elC4AmnF6H0PKyiio" 80 | assert result == "#{@prefix}/#{signature}/rs:fill:123:456:true/#{@img_url_encoded}" 81 | end 82 | 83 | test "support multiple options" do 84 | result = 85 | @img_url 86 | |> Imgproxy.new() 87 | |> Imgproxy.resize(123, 456, type: "fill", enlarge: true) 88 | |> Imgproxy.set_gravity("sm") 89 | |> to_string() 90 | 91 | signature = "SCMuOeSYIRAA1nxJbuuKXnvRBsW0X50xjhqJz_xSDf4" 92 | assert result == "#{@prefix}/#{signature}/g:sm/rs:fill:123:456:true/#{@img_url_encoded}" 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "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"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [: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", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 8 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 9 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 10 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 11 | "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"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 14 | } 15 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "test/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: true, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | # 52 | ## Consistency Checks 53 | # 54 | {Credo.Check.Consistency.ExceptionNames, []}, 55 | {Credo.Check.Consistency.LineEndings, []}, 56 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 57 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 58 | {Credo.Check.Consistency.SpaceInParentheses, []}, 59 | {Credo.Check.Consistency.TabsOrSpaces, []}, 60 | 61 | # 62 | ## Design Checks 63 | # 64 | # You can customize the priority of any check 65 | # Priority values are: `low, normal, high, higher` 66 | # 67 | {Credo.Check.Design.AliasUsage, 68 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 69 | # You can also customize the exit_status of each check. 70 | # If you don't want TODO comments to cause `mix credo` to fail, just 71 | # set this value to 0 (zero). 72 | # 73 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 74 | {Credo.Check.Design.TagFIXME, []}, 75 | 76 | # 77 | ## Readability Checks 78 | # 79 | {Credo.Check.Readability.AliasOrder, []}, 80 | {Credo.Check.Readability.FunctionNames, []}, 81 | {Credo.Check.Readability.LargeNumbers, []}, 82 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 83 | {Credo.Check.Readability.ModuleAttributeNames, []}, 84 | {Credo.Check.Readability.ModuleDoc, false}, 85 | {Credo.Check.Readability.ModuleNames, []}, 86 | {Credo.Check.Readability.ParenthesesInCondition, []}, 87 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 88 | {Credo.Check.Readability.PredicateFunctionNames, []}, 89 | {Credo.Check.Readability.PreferImplicitTry, []}, 90 | {Credo.Check.Readability.RedundantBlankLines, []}, 91 | {Credo.Check.Readability.Semicolons, []}, 92 | {Credo.Check.Readability.SpaceAfterCommas, []}, 93 | {Credo.Check.Readability.StringSigils, []}, 94 | {Credo.Check.Readability.TrailingBlankLine, []}, 95 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 96 | {Credo.Check.Readability.VariableNames, []}, 97 | 98 | # 99 | ## Refactoring Opportunities 100 | # 101 | {Credo.Check.Refactor.CondStatements, []}, 102 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 103 | {Credo.Check.Refactor.FunctionArity, []}, 104 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 105 | {Credo.Check.Refactor.MapInto, false}, 106 | {Credo.Check.Refactor.MatchInCondition, []}, 107 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 108 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 109 | {Credo.Check.Refactor.Nesting, []}, 110 | {Credo.Check.Refactor.PipeChainStart, 111 | [excluded_argument_types: [:atom, :binary, :fn, :keyword], excluded_functions: []]}, 112 | {Credo.Check.Refactor.UnlessWithElse, []}, 113 | 114 | # 115 | ## Warnings 116 | # 117 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 118 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 119 | {Credo.Check.Warning.IExPry, []}, 120 | {Credo.Check.Warning.IoInspect, []}, 121 | {Credo.Check.Warning.LazyLogging, false}, 122 | {Credo.Check.Warning.OperationOnSameValues, []}, 123 | {Credo.Check.Warning.OperationWithConstantResult, []}, 124 | {Credo.Check.Warning.RaiseInsideRescue, []}, 125 | {Credo.Check.Warning.UnusedEnumOperation, []}, 126 | {Credo.Check.Warning.UnusedFileOperation, []}, 127 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 128 | {Credo.Check.Warning.UnusedListOperation, []}, 129 | {Credo.Check.Warning.UnusedPathOperation, []}, 130 | {Credo.Check.Warning.UnusedRegexOperation, []}, 131 | {Credo.Check.Warning.UnusedStringOperation, []}, 132 | {Credo.Check.Warning.UnusedTupleOperation, []}, 133 | 134 | # 135 | # Controversial and experimental checks (opt-in, just remove `, false`) 136 | # 137 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 138 | {Credo.Check.Design.DuplicatedCode, false}, 139 | {Credo.Check.Readability.Specs, false}, 140 | {Credo.Check.Refactor.ABCSize, false}, 141 | {Credo.Check.Refactor.AppendSingleItem, false}, 142 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 143 | {Credo.Check.Refactor.VariableRebinding, false}, 144 | {Credo.Check.Warning.MapGetUnsafePass, false}, 145 | {Credo.Check.Warning.UnsafeToAtom, false} 146 | 147 | # 148 | # Custom checks can be created using `mix credo.gen.check`. 149 | # 150 | ] 151 | } 152 | ] 153 | } 154 | -------------------------------------------------------------------------------- /lib/imgproxy.ex: -------------------------------------------------------------------------------- 1 | defmodule Imgproxy do 2 | @moduledoc """ 3 | `Imgproxy` generates urls for use with an [imgproxy](https://imgproxy.net) server. 4 | """ 5 | 6 | defstruct source_url: nil, 7 | options: [], 8 | extension: nil, 9 | prefix: nil, 10 | key: nil, 11 | salt: nil, 12 | endpoint: "/", 13 | source_url_encoding: :base64 14 | 15 | alias __MODULE__ 16 | 17 | @source_url_encodings [:plain, :base64] 18 | @type source_url_encoding :: :plain | :base64 19 | 20 | @type t :: %__MODULE__{ 21 | source_url: nil | String.t(), 22 | options: keyword(list()), 23 | extension: nil | String.t(), 24 | prefix: nil | String.t(), 25 | key: nil | String.t(), 26 | salt: nil | String.t(), 27 | endpoint: String.t(), 28 | source_url_encoding: source_url_encoding() 29 | } 30 | 31 | @typedoc """ 32 | A number of pixels to be used as a dimension. 33 | """ 34 | @type dimension :: float() | integer() | String.t() 35 | 36 | @typedoc """ 37 | Provide type and enlarge configuration arguments to a resize option. 38 | """ 39 | @type resize_opts :: [ 40 | type: String.t(), 41 | enlarge: boolean() 42 | ] 43 | 44 | @doc """ 45 | Generate a new `t:Imgproxy.t/0` struct for the given image source URL. 46 | """ 47 | @spec new(String.t()) :: t() 48 | def new(source_url) when is_binary(source_url) do 49 | %Imgproxy{ 50 | source_url: source_url, 51 | prefix: Application.get_env(:imgproxy, :prefix), 52 | key: Application.get_env(:imgproxy, :key), 53 | salt: Application.get_env(:imgproxy, :salt) 54 | } 55 | end 56 | 57 | @doc """ 58 | Generate a new `t:Imgproxy.t/0` struct for the given image source URL to fetch the 59 | [Info Endpoint](https://docs.imgproxy.net/usage/getting_info). 60 | """ 61 | @spec info_new(String.t()) :: t() 62 | def info_new(source_url) when is_binary(source_url) do 63 | %{new(source_url) | endpoint: "/info"} 64 | end 65 | 66 | @doc """ 67 | Add a [formatting option](https://docs.imgproxy.net/generating_the_url_advanced) to the `t:Imgproxy.t/0`. 68 | 69 | For instance, to add the [padding](https://docs.imgproxy.net/generating_the_url_advanced?id=padding) option 70 | with a 10px padding on all sides, you can use: 71 | 72 | iex> img = Imgproxy.new("http://example.com/image.jpg") 73 | iex> Imgproxy.add_option(img, :padding, [10, 10, 10, 10]) |> to_string() 74 | "https://imgcdn.example.com/insecure/padding:10:10:10:10/aHR0cDovL2V4YW1wbGUuY29tL2ltYWdlLmpwZw" 75 | 76 | """ 77 | @spec add_option(t(), atom(), list()) :: t() 78 | def add_option(%Imgproxy{options: opts} = img, name, args) 79 | when is_atom(name) and is_list(args) do 80 | %{img | options: Keyword.put(opts, name, args)} 81 | end 82 | 83 | @doc """ 84 | Set the [gravity](https://docs.imgproxy.net/generating_the_url_advanced?id=gravity) option. 85 | """ 86 | @spec set_gravity(t(), atom(), dimension(), dimension()) :: t() 87 | def set_gravity(img, type, xoffset \\ 0, yoffset \\ 0) 88 | 89 | def set_gravity(img, "sm", _xoffset, _yoffset) do 90 | add_option(img, :g, [:sm]) 91 | end 92 | 93 | def set_gravity(img, :sm, _xoffset, _yoffset) do 94 | add_option(img, :g, [:sm]) 95 | end 96 | 97 | def set_gravity(img, type, xoffset, yoffset) do 98 | add_option(img, :g, [type, xoffset, yoffset]) 99 | end 100 | 101 | @doc """ 102 | [Resize](https://docs.imgproxy.net/generating_the_url_advanced?id=resize) an image to the given width and height. 103 | 104 | Options include: 105 | * type: "fit" (default), "fill", or "auto" 106 | * enlarge: enlarge if necessary (`false` by default) 107 | """ 108 | @spec resize(t(), dimension(), dimension(), resize_opts()) :: t() 109 | def resize(img, width, height, opts \\ []) do 110 | type = Keyword.get(opts, :type, "fit") 111 | enlarge = Keyword.get(opts, :enlarge, false) 112 | add_option(img, :rs, [type, width, height, enlarge]) 113 | end 114 | 115 | @doc """ 116 | [Crop](https://docs.imgproxy.net/generating_the_url_advanced?id=crop) an image to the given width and height. 117 | 118 | Accepts an optional [gravity](https://docs.imgproxy.net/generating_the_url_advanced?id=gravity) parameter, by 119 | default it is "ce:0:0" for center gravity with no offset. 120 | """ 121 | @spec crop(t(), dimension(), dimension(), String.t()) :: t() 122 | def crop(img, width, height, gravity \\ "ce:0:0") do 123 | add_option(img, :c, [width, height, gravity]) 124 | end 125 | 126 | @doc """ 127 | Set the file extension (which will produce an image of that type). 128 | 129 | For instance, setting the extension to "png" will result in a PNG being created: 130 | 131 | iex> img = Imgproxy.new("http://example.com/image.jpg") 132 | iex> Imgproxy.set_extension(img, "png") |> to_string() 133 | "https://imgcdn.example.com/insecure/aHR0cDovL2V4YW1wbGUuY29tL2ltYWdlLmpwZw.png" 134 | 135 | """ 136 | @spec set_extension(t(), String.t()) :: t() 137 | def set_extension(img, "." <> extension), do: set_extension(img, extension) 138 | 139 | def set_extension(img, extension), do: %{img | extension: extension} 140 | 141 | @doc """ 142 | Set [the source URL encoding](https://docs.imgproxy.net/usage/processing#source-url) - the default is `:base64`. 143 | 144 | When the encoding is set to `:plain`, the source URL is prepended with `plain/`, 145 | the characters `%`, `?`, and `@` are percent-encoded 146 | and any file extension is added using `@extension` syntax. 147 | 148 | ## Examples 149 | 150 | iex> img = Imgproxy.new("https://placekitten.com/200/300?code=%@") 151 | iex> Imgproxy.set_source_url_encoding(img, :plain) |> to_string() 152 | "https://imgcdn.example.com/insecure/plain/https://placekitten.com/200/300%3Fcode=%25%40" 153 | 154 | iex> "https://placekitten.com/200/300" 155 | ...> |> Imgproxy.new() 156 | ...> |> Imgproxy.set_extension("png") 157 | ...> |> Imgproxy.set_source_url_encoding(:plain) 158 | ...> |> to_string() 159 | "https://imgcdn.example.com/insecure/plain/https://placekitten.com/200/300@png" 160 | 161 | iex> "https://placekitten.com/200/300" 162 | ...> |> Imgproxy.new() 163 | ...> |> Imgproxy.set_source_url_encoding(:unknown) 164 | ** (FunctionClauseError) no function clause matching in Imgproxy.set_source_url_encoding/2 165 | 166 | """ 167 | 168 | @spec set_source_url_encoding(t(), source_url_encoding()) :: t() 169 | def set_source_url_encoding(img, encoding) when encoding in @source_url_encodings do 170 | %{img | source_url_encoding: encoding} 171 | end 172 | 173 | @doc """ 174 | Generate an imgproxy URL. 175 | 176 | ## Example 177 | 178 | iex> Imgproxy.to_string(Imgproxy.new("https://placekitten.com/200/300")) 179 | "https://imgcdn.example.com/insecure/aHR0cHM6Ly9wbGFjZWtpdHRlbi5jb20vMjAwLzMwMA" 180 | 181 | """ 182 | @spec to_string(t()) :: String.t() 183 | defdelegate to_string(img), to: String.Chars.Imgproxy 184 | end 185 | 186 | defimpl String.Chars, for: Imgproxy do 187 | def to_string(%Imgproxy{prefix: prefix, key: key, salt: salt, endpoint: endpoint} = img) do 188 | path = build_path(img) 189 | signature = gen_signature(path, key, salt) 190 | Path.join([prefix || "", endpoint, signature, path]) 191 | end 192 | 193 | defp build_path(%Imgproxy{options: opts} = img) do 194 | ["/" | Enum.map(opts, &option_to_string/1)] 195 | |> Path.join() 196 | |> Path.join(prepare_source_url(img)) 197 | end 198 | 199 | defp prepare_source_url(img) do 200 | img.source_url 201 | |> optionally_encode_source_url(img) 202 | |> optionally_add_extension(img) 203 | end 204 | 205 | defp optionally_encode_source_url(source_url, %Imgproxy{source_url_encoding: :base64}) do 206 | Base.url_encode64(source_url, padding: false) 207 | end 208 | 209 | @plain_source_url_blacklist ~c"%?@" 210 | defp optionally_encode_source_url(source_url, _img) do 211 | encoded = URI.encode(source_url, &(&1 not in @plain_source_url_blacklist)) 212 | Path.join("plain", encoded) 213 | end 214 | 215 | defp optionally_add_extension(source_url, %Imgproxy{extension: nil}) do 216 | source_url 217 | end 218 | 219 | defp optionally_add_extension(source_url, %Imgproxy{source_url_encoding: :plain} = img) do 220 | source_url <> "@" <> img.extension 221 | end 222 | 223 | defp optionally_add_extension(source_url, %Imgproxy{extension: extension}) do 224 | source_url <> "." <> extension 225 | end 226 | 227 | defp option_to_string({name, args}) when is_list(args) do 228 | Enum.map_join([name | args], ":", &Kernel.to_string/1) 229 | end 230 | 231 | defp gen_signature(path, key, salt) when is_binary(key) and is_binary(salt) do 232 | decoded_key = Base.decode16!(key, case: :lower) 233 | decoded_salt = Base.decode16!(salt, case: :lower) 234 | 235 | :hmac 236 | |> :crypto.mac(:sha256, decoded_key, decoded_salt <> path) 237 | |> Base.url_encode64(padding: false) 238 | end 239 | 240 | defp gen_signature(_path, _key, _salt), do: "insecure" 241 | end 242 | --------------------------------------------------------------------------------