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 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", ~s("#{image.id}")) |> send_resp(200, image.data) 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 thumb_path = Media.get_thumbnail_path(id) if File.exists?(thumb_path) do # Serve from disk cache conn |> put_resp_content_type("image/jpeg") |> put_resp_header("cache-control", "public, max-age=31536000, immutable") |> put_resp_header("etag", ~s("#{id}-thumb")) |> send_file(200, thumb_path) else # Thumbnail not yet generated - generate on-demand 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", ~s("#{id}-thumb")) |> send_resp(200, binary) {:error, _} -> # Fallback to full image if thumbnail generation fails 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 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(), %{data: data} when is_binary(data) <- Media.get_image(id) do 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", ~s("#{id}-#{width}.#{format}")) |> send_file(200, path) {:error, _reason} -> send_resp(conn, 500, "Failed to generate variant") end else :error -> send_resp(conn, 400, "Invalid width or format") false -> send_resp(conn, 400, "Width not supported") nil -> send_resp(conn, 404, "Image not found") %{data: nil} -> send_resp(conn, 404, "Image data not available") 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 end