berrypod/lib/simpleshop_theme_web/controllers/image_controller.ex

226 lines
6.7 KiB
Elixir
Raw Normal View History

defmodule SimpleshopThemeWeb.ImageController do
use SimpleshopThemeWeb, :controller
alias SimpleshopTheme.Media
alias SimpleshopTheme.Media.SVGRecolorer
@doc """
Serves an image from the database by ID.
Images are served with aggressive caching headers since they are
immutable once uploaded.
"""
def show(conn, %{"id" => id}) do
etag = ~s("#{id}")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
image ->
conn
|> put_resp_content_type(image.content_type)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, image.data)
end
end
end
@doc """
Serves a thumbnail of an image from the disk cache.
Thumbnails are generated by the background image optimization pipeline.
If the thumbnail doesn't exist on disk yet (still processing), generates
it on-demand and saves it for future requests.
"""
def thumbnail(conn, %{"id" => id}) do
etag = ~s("#{id}-thumb")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
thumb_path = Media.get_thumbnail_path(id)
if File.exists?(thumb_path) do
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, thumb_path)
else
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
%{data: data} when is_binary(data) ->
case generate_thumbnail_on_demand(data, thumb_path) do
{:ok, binary} ->
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, binary)
{:error, _} ->
conn
|> put_resp_content_type("image/webp")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_resp(200, data)
end
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end
end
end
defp generate_thumbnail_on_demand(image_data, thumb_path) do
with {:ok, image} <- Image.from_binary(image_data),
{:ok, thumb} <- Image.thumbnail(image, 200),
{:ok, binary} <- Image.write(thumb, :memory, suffix: ".jpg", quality: 80) do
# Ensure cache directory exists and save for future requests
File.mkdir_p!(Path.dirname(thumb_path))
File.write!(thumb_path, binary)
{:ok, binary}
end
rescue
_ -> {:error, :thumbnail_generation_failed}
end
@supported_formats %{"avif" => :avif, "webp" => :webp, "jpg" => :jpg}
@doc """
Serves an image variant at the specified width and format.
Supports AVIF, WebP, and JPEG formats. If the variant doesn't exist on disk
(e.g., cache was deleted), it will be generated on-demand and cached.
JPEG variants are never pre-generated (to save disk space), so they are
always generated on first request.
## URL format
/images/:id/variant/:width.:format
Examples:
- `/images/abc123/variant/800.avif`
- `/images/abc123/variant/400.webp`
- `/images/abc123/variant/1200.jpg`
"""
def variant(conn, %{"id" => id, "width" => width_with_ext}) do
alias SimpleshopTheme.Images.Optimizer
with {width, format} <- parse_width_and_format(width_with_ext),
true <- width in Optimizer.all_widths() do
etag = ~s("#{id}-#{width}.#{format}")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
case Media.get_image(id) do
%{data: data} when is_binary(data) ->
case Optimizer.generate_variant_on_demand(data, id, width, format) do
{:ok, path} ->
conn
|> put_resp_content_type(format_content_type(format))
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, path)
{:error, _reason} ->
send_resp(conn, 500, "Failed to generate variant")
end
nil ->
send_resp(conn, 404, "Image not found")
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end
else
:error -> send_resp(conn, 400, "Invalid width or format")
false -> send_resp(conn, 400, "Width not supported")
end
end
defp parse_width_and_format(width_with_ext) do
case String.split(width_with_ext, ".") do
[width_str, ext] when is_map_key(@supported_formats, ext) ->
case Integer.parse(width_str) do
{width, ""} -> {width, @supported_formats[ext]}
_ -> :error
end
_ ->
:error
end
end
defp format_content_type(:avif), do: "image/avif"
defp format_content_type(:webp), do: "image/webp"
defp format_content_type(:jpg), do: "image/jpeg"
@doc """
Serves an SVG image recolored with the specified color.
The color should be a hex color code (with or without the leading #).
Only works with SVG images.
"""
def recolored_svg(conn, %{"id" => id, "color" => color}) do
clean_color = normalize_color(color)
with true <- SVGRecolorer.valid_hex_color?(clean_color),
%{is_svg: true, svg_content: svg} when not is_nil(svg) <- Media.get_image(id) do
recolored = SVGRecolorer.recolor(svg, clean_color)
conn
|> put_resp_content_type("image/svg+xml")
|> put_resp_header("cache-control", "public, max-age=3600")
|> put_resp_header("etag", ~s("#{id}-#{clean_color}"))
|> send_resp(200, recolored)
else
false ->
send_resp(conn, 400, "Invalid color format")
nil ->
send_resp(conn, 404, "Image not found")
%{is_svg: false} ->
send_resp(conn, 400, "Image is not an SVG")
%{svg_content: nil} ->
send_resp(conn, 400, "SVG content not available")
end
end
defp normalize_color(color) do
color = String.trim(color)
if String.starts_with?(color, "#") do
color
else
"#" <> color
end
end
defp etag_match?(conn, etag) do
case Plug.Conn.get_req_header(conn, "if-none-match") do
[^etag] -> true
_ -> false
end
end
defp send_not_modified(conn, etag) do
conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(304, "")
end
end