673 words, 4 min read

A common need in admin tools: click a button, download a remote image as a square JPEG. Simple enough — until CORS gets in the way.

The CORS problem

When fetching a cross-origin image and drawing it onto a <canvas>, the browser marks the canvas as "tainted". The moment you call canvas.toBlob() to read the pixel data back out, it throws a security error. Unless the image server sends explicit CORS headers — which most don't — you can't do canvas operations on cross-origin images.

The fix: a server-side proxy

The solution is to proxy the image through the Phoenix app. From the browser's point of view the image comes from the same origin, so the canvas stays clean.

def image_proxy(conn, %{"url" => url}) do
with true <- allowed_url?(url),
{:ok, response} <- Req.get(url) do
content_type =
response.headers
|> Map.get("content-type", ["image/jpeg"])
|> List.first()
|> String.split(";")
|> hd()
conn
|> put_resp_content_type(content_type)
|> send_resp(200, response.body)
else
false -> send_resp(conn, 400, "Invalid URL")
_ -> send_resp(conn, 502, "Failed to fetch image")
end
end

The allowed_url?/1 function validates the host against a known allowlist — an important SSRF guard so the proxy can't be abused to fetch arbitrary internal URLs.

Center-cropping to a square in the browser

Once the image loads from the proxy, a small Canvas API snippet handles the crop. The logic is straightforward: use the shorter dimension as the square size, then offset into the longer dimension by half the difference to take the center slice.

window.addEventListener("phx:download-square-image", (event) => {
const { url, filename } = event.detail;
const proxyUrl = `/image-proxy?url=${encodeURIComponent(url)}`;
const img = new Image();
img.onload = () => {
const size = Math.min(img.naturalWidth, img.naturalHeight);
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const offsetX = (img.naturalWidth - size) / 2;
const offsetY = (img.naturalHeight - size) / 2;
ctx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size);
canvas.toBlob((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}, "image/jpeg", 0.95);
};
img.src = proxyUrl;
});

For a landscape image the left and right edges are trimmed, keeping the center. For a portrait image the top and bottom are trimmed instead. The full shorter dimension is always preserved — no upscaling, no padding.

Wiring it up in LiveView

The download is triggered from the template using Phoenix's JS.dispatch/2, which fires a custom DOM event with the image URL and filename as detail:

<button
class="cursor-pointer"
phx-click={
JS.dispatch("phx:download-square-image",
detail: %{url: @image_url, filename: "image.jpg"}
)
}
>
Download
</button>

No LiveView round-trip needed — JS.dispatch fires the event directly in the browser, the window listener catches it, and the download happens entirely client-side after the one proxy fetch.

Why naturalWidth instead of width?

img.width returns the CSS-rendered size, which is meaningless for an image that isn't attached to the DOM. img.naturalWidth always returns the actual pixel dimensions of the image data — the right value to use when doing pixel-level canvas operations.