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