Packages

phoenix_kit

2.0.0
2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage services image_processor.ex
Raw

lib/modules/storage/services/image_processor.ex

defmodule PhoenixKit.Modules.Storage.ImageProcessor do
@moduledoc """
ImageMagick-based image processing module.
Handles image operations using ImageMagick command-line tools:
- `identify` - Extract image metadata (dimensions, format)
- `convert`/`magick` - Resize and format conversion
This replaces Vix with ImageMagick, which is more widely trusted
and has better long-term support.
"""
require Logger
@doc """
Get the width of an image file using ImageMagick identify.
Returns the width in pixels or nil if extraction fails.
"""
def get_width(file_path) do
case extract_dimensions(file_path) do
{:ok, {width, _height}} -> width
{:error, _reason} -> nil
end
end
@doc """
Get the height of an image file using ImageMagick identify.
Returns the height in pixels or nil if extraction fails.
"""
def get_height(file_path) do
case extract_dimensions(file_path) do
{:ok, {_width, height}} -> height
{:error, _reason} -> nil
end
end
@doc """
Extract both width and height from an image file.
Uses ImageMagick's `identify` command to extract image dimensions.
Returns:
- `{:ok, {width, height}}` - Dimensions in pixels
- `{:error, reason}` - If extraction fails
"""
def extract_dimensions(file_path) do
case System.cmd("identify", ["-format", "%wx%h", file_path], stderr_to_stdout: true) do
{output, 0} ->
case String.split(String.trim(output), "x") do
[width_str, height_str] ->
case {Integer.parse(width_str), Integer.parse(height_str)} do
{{width, ""}, {height, ""}} ->
{:ok, {width, height}}
_ ->
{:error, "Failed to parse dimensions: #{output}"}
end
_ ->
{:error, "Invalid dimension format: #{output}"}
end
{output, exit_code} ->
{:error, "identify failed with exit code #{exit_code}: #{output}"}
end
rescue
e ->
{:error, "Failed to extract dimensions: #{inspect(e)}"}
end
@doc """
Resize an image to fit within specified dimensions.
Maintains aspect ratio by scaling to fit within bounds.
Optionally converts format based on output_format parameter.
Parameters:
- `input_path` - Path to input image file
- `output_path` - Path to save resized image
- `width` - Target width (nil to use original)
- `height` - Target height (nil to use original)
- `opts` - Additional options
- `:quality` - JPEG quality 1-100 (default: 85)
- `:format` - Output format override (jpg, png, webp, etc)
Returns:
- `{:ok, output_path}` - Success
- `{:error, reason}` - If resize fails
"""
def resize(input_path, output_path, width, height, opts \\ []) do
quality = Keyword.get(opts, :quality, 85)
format = Keyword.get(opts, :format, nil)
# Extract current dimensions
case extract_dimensions(input_path) do
{:ok, {current_width, current_height}} ->
# Calculate resize parameters
resize_spec = calculate_resize_spec(current_width, current_height, width, height)
# Build ImageMagick convert command
args = build_convert_args(input_path, output_path, resize_spec, quality, format)
Logger.info(
"Resizing image: #{input_path} -> #{output_path}, resize spec: #{resize_spec}"
)
case System.cmd("convert", args, stderr_to_stdout: true) do
{_output, 0} ->
Logger.info("Successfully resized image to #{output_path}")
{:ok, output_path}
{output, exit_code} ->
Logger.error("convert failed with exit code #{exit_code}: #{output}")
{:error, "ImageMagick convert failed: #{output}"}
end
{:error, reason} ->
{:error, "Failed to extract image dimensions: #{reason}"}
end
rescue
e ->
Logger.error("Image resize failed: #{inspect(e)}")
{:error, "Image resize failed: #{inspect(e)}"}
end
@doc """
Resize and center-crop an image to exact dimensions.
Zooms into the image to fill the target dimensions completely, then
center-crops to extract the exact target size. No padding borders - the
entire output is filled with the image content.
This is ideal for thumbnails where you want perfect squares (e.g., 150x150)
with the image zoomed in and centered, no white/black borders.
The algorithm:
1. Resizes image to fill the target box (scales to cover both dimensions)
2. Centers the image using gravity
3. Crops from center to exact target dimensions
Parameters:
- `input_path` - Path to input image file
- `output_path` - Path to save cropped image
- `width` - Target width (required)
- `height` - Target height (required)
- `opts` - Additional options
- `:quality` - JPEG quality 1-100 (default: 85)
- `:format` - Output format override (jpg, png, webp, etc)
- `:background` - Background color (rarely used, default: "white")
Returns:
- `{:ok, output_path}` - Success
- `{:error, reason}` - If processing fails
"""
def resize_and_crop_center(input_path, output_path, width, height, opts \\ []) do
quality = Keyword.get(opts, :quality, 85)
format = Keyword.get(opts, :format, nil)
alpha? = has_alpha_channel?(input_path)
background =
if Keyword.has_key?(opts, :background) do
Keyword.get(opts, :background, "white")
else
if alpha?, do: "none", else: "white"
end
format =
if format == "jpg" and alpha? do
Logger.warning(
"Image #{input_path} has alpha channel but format is jpg — overriding to webp to preserve transparency"
)
"webp"
else
format
end
if is_nil(width) or is_nil(height) do
{:error, "Both width and height are required for center-crop resizing"}
else
Logger.info(
"Center-cropping image: #{input_path} -> #{output_path}, target: #{width}x#{height}"
)
# Build ImageMagick convert command for center-crop
args =
build_center_crop_args(
input_path,
output_path,
width,
height,
quality,
format,
background
)
case System.cmd("convert", args, stderr_to_stdout: true) do
{_output, 0} ->
Logger.info("Successfully center-cropped image to #{output_path}")
{:ok, output_path}
{output, exit_code} ->
Logger.error("convert failed with exit code #{exit_code}: #{output}")
{:error, "ImageMagick convert failed: #{output}"}
end
end
rescue
e ->
Logger.error("Image center-crop failed: #{inspect(e)}")
{:error, "Image center-crop failed: #{inspect(e)}"}
end
# 40 megapixels — comfortably above any real camera or screenshot, far
# below what it takes to hurt. `-resize` bounds the OUTPUT; the decoder
# still rasterizes the input in full first, so a 5MB PNG declaring
# 50000x50000 is ~10GB of RAM before a single pixel is written. The
# `-limit` flags turn that into a failure rather than an outage, but
# reading the header and refusing costs nothing and never starts it.
@sanitize_max_pixels 40_000_000
defp check_pixel_budget(width, height, max_pixels)
when is_integer(width) and is_integer(height) and width * height > max_pixels,
do: {:error, "image is too large"}
defp check_pixel_budget(_width, _height, _max_pixels), do: :ok
@doc """
Re-encodes an image to known-good bytes, discarding everything else.
For uploads from people you do not trust. The point is not to *inspect*
the file — that is a losing game — but to stop serving the uploader's
bytes at all: what ends up stored is this encoder's output, produced by
decoding the input and writing a fresh image. A polyglot file (valid
GIF, valid JavaScript), an EXIF payload, a trailing ZIP, an embedded
colour profile — none of them survive being decoded to pixels and
written out again.
Also enforces a maximum edge length, because a 30000×30000 PNG is a
decompression bomb whatever its byte size, and rejects anything
ImageMagick cannot read as an image regardless of what the upload
claimed to be.
Returns `{:ok, output_path}` or `{:error, reason}`. Never raises.
## ⚠️ Nothing in core calls this yet
It is a primitive, not a policy: no upload path in PhoenixKit routes
through it, so adding it did not change what any existing endpoint
stores. A caller that accepts untrusted uploads — a public portal
submission, an avatar from an unauthenticated form — has to invoke it
itself and store the OUTPUT path, discarding the original. Wiring it
into `Storage.upload/*` wholesale is not a drop-in: it re-encodes, which
is right for images from strangers and wrong for a designer uploading a
master PNG they expect back byte-for-byte.
## Options
* `:max_edge` — longest side in pixels (default 2500); larger images
are scaled down, never up
* `:quality` — output quality (default 82)
* `:format` — output format (default "jpeg"; use "png" to keep
transparency)
## Example
ImageProcessor.sanitize(upload.path, dest, format: "png")
"""
@spec sanitize(String.t(), String.t(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
def sanitize(input_path, output_path, opts \\ []) do
max_edge = Keyword.get(opts, :max_edge, 2500)
quality = Keyword.get(opts, :quality, 82)
format = Keyword.get(opts, :format, "jpeg")
max_pixels = Keyword.get(opts, :max_pixels, @sanitize_max_pixels)
with {:ok, {width, height}} <- extract_dimensions(input_path),
:ok <- check_pixel_budget(width, height, max_pixels),
{:ok, detected} <- detect_format(input_path) do
args =
[
# Resource ceilings, applied HERE rather than trusted to the
# host's policy.xml. A decompression bomb is a small file that
# decodes to gigabytes, and "the sysadmin configured ImageMagick
# correctly" is not a control this code can rely on.
"-limit",
"memory",
"256MiB",
"-limit",
"map",
"512MiB",
"-limit",
"disk",
"1GiB",
"-limit",
"time",
"20"
] ++
[
# `[0]` takes the FIRST frame only: without it a multi-frame
# GIF writes N output files, which is a cheap way to burn disk.
# The format prefix pins how the bytes are decoded, so a file
# can never be interpreted as a coder we did not allow.
"#{detected}:#{input_path}[0]",
"-auto-orient",
# Strip metadata before anything else: EXIF, IPTC, XMP, colour
# profiles, comments. GPS coordinates in a bug report screenshot
# are a privacy leak the reporter did not intend.
"-strip",
"-alpha",
if(format == "png", do: "on", else: "remove"),
"-resize",
"#{max_edge}x#{max_edge}>",
"-quality",
Integer.to_string(quality),
"#{format}:#{output_path}"
]
Logger.info(
"Sanitizing #{detected} upload #{input_path} (#{width}x#{height}) -> #{output_path}"
)
case System.cmd("convert", args, stderr_to_stdout: true) do
{_output, 0} ->
{:ok, output_path}
{output, exit_code} ->
Logger.warning("Upload sanitize failed (#{exit_code}): #{output}")
{:error, "could not process image"}
end
else
{:error, reason} -> {:error, reason}
end
rescue
e ->
Logger.warning("Upload sanitize raised: #{Exception.message(e)}")
{:error, "could not process image"}
end
# Private functions
# What ImageMagick actually thinks this is, checked against a short
# allowlist. The extension and the browser's content-type are both the
# uploader's claims; this is the only reading that counts, and it does
# not depend on the host having configured policy.xml.
@sanitize_formats ~w(PNG JPEG JPG WEBP GIF)
defp detect_format(path) do
case System.cmd("identify", ["-format", "%m", "#{path}[0]"], stderr_to_stdout: true) do
{output, 0} ->
format = output |> String.trim() |> String.upcase()
if format in @sanitize_formats,
do: {:ok, format},
else: {:error, "unsupported image format"}
_ ->
{:error, "not a readable image"}
end
rescue
_ -> {:error, "not a readable image"}
end
defp calculate_resize_spec(current_width, current_height, target_width, target_height) do
case {target_width, target_height} do
{w, h} when w != nil and h != nil ->
# Both width and height specified - fit within bounds preserving aspect ratio
# Use ImageMagick's extent notation: scale to fit, then extend to exact size if needed
# The '>' suffix means only shrink, never enlarge
"#{w}x#{h}>"
{w, nil} when w != nil ->
# Only width specified - maintain aspect ratio
"#{w}x"
{nil, h} when h != nil ->
# Only height specified - maintain aspect ratio
"x#{h}"
_ ->
# No dimensions - return original size
"#{current_width}x#{current_height}"
end
end
defp build_convert_args(input_path, output_path, resize_spec, quality, format) do
args = [input_path]
# Add resize operation
args = args ++ ["-resize", resize_spec]
# Add quality setting for JPEG (ImageMagick quality for lossy formats)
args = args ++ ["-quality", to_string(quality)]
# Add format conversion if specified
args =
if format do
format_spec = "#{format}:#{output_path}"
args ++ [format_spec]
else
args ++ [output_path]
end
args
end
defp build_center_crop_args(input_path, output_path, width, height, quality, format, background) do
args = [input_path]
# Set background color for padding/extension (rarely used with ^ resize)
args = args ++ ["-background", background]
# Resize to fill/cover the target dimensions (with the ^ flag)
# The ^ flag means "resize to FILL the box" - scales up to ensure both dimensions
# are at least the target size, creating overflow that gets cropped
resize_spec = "#{width}x#{height}^"
args = args ++ ["-resize", resize_spec]
# Use gravity center to position image at center before cropping
args = args ++ ["-gravity", "center"]
# Crop to exact dimensions from the centered position
args = args ++ ["-extent", "#{width}x#{height}"]
# Add quality setting for JPEG (ImageMagick quality for lossy formats)
args = args ++ ["-quality", to_string(quality)]
# Add format conversion if specified
args =
if format do
format_spec = "#{format}:#{output_path}"
args ++ [format_spec]
else
args ++ [output_path]
end
args
end
defp has_alpha_channel?(file_path) do
case System.cmd("identify", ["-format", "%[channels]", file_path], stderr_to_stdout: true) do
{output, 0} -> String.contains?(String.trim(output), "a")
_ -> false
end
rescue
_ -> false
end
end