consolidate image serving and clean up pipeline

Move all image URL logic into ProductImage.url/2 and thumbnail_url/1,
remove dead on-demand generation code from Optimizer, strip controller
routes down to SVG recolor only, fix mockup startup check to verify all
variant formats, and isolate test image cache directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-16 17:47:41 +00:00
parent 81e94d0d65
commit bb358f890b
21 changed files with 134 additions and 428 deletions

View File

@@ -414,7 +414,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
end
defp cart_item_image(product) do
ProductImage.direct_url(Product.primary_image(product), 400)
ProductImage.url(Product.primary_image(product), 400)
end
# Shared delivery line used by both cart_drawer and order_summary.

View File

@@ -1046,15 +1046,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available)
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-"
assigns =
assigns
|> assign(:available_widths, available)
|> assign(:default_width, default_width)
|> assign(:separator, separator)
~H"""
<picture>
@@ -1069,7 +1064,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
sizes={@sizes}
/>
<img
src={"#{@src}#{@separator}#{@default_width}.jpg"}
src={"#{@src}-#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes}
alt={@alt}
@@ -1163,12 +1158,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
end
defp build_srcset(base, widths, format) do
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(base, "/"), do: "", else: "-"
widths
|> Enum.sort()
|> Enum.map_join(", ", &"#{base}#{separator}#{&1}.#{format} #{&1}w")
|> Enum.map_join(", ", &"#{base}-#{&1}.#{format} #{&1}w")
end
end

View File

@@ -382,7 +382,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|> Enum.with_index()
|> Enum.map(fn {product, idx} ->
image = Product.primary_image(product)
%{product: product, image_url: ProductImage.direct_url(image, 400), idx: idx}
%{product: product, image_url: ProductImage.url(image, 400), idx: idx}
end)
)
@@ -880,7 +880,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
"/images/#{logo_image.id}/recolored/#{clean_color}"
end
defp logo_url(logo_image, _), do: "/images/#{logo_image.id}"
defp logo_url(logo_image, _), do: "/image_cache/#{logo_image.id}.webp"
# Logo content that links to home, except when already on home page.
# This follows accessibility best practices - current page should not be a link.
@@ -970,7 +970,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
defp header_background_style(settings, header_image) do
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
"background-image: url('/images/#{header_image.id}'); " <>
"background-image: url('/image_cache/#{header_image.id}.webp'); " <>
"background-size: #{settings.header_zoom}%; " <>
"background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <>
"background-repeat: no-repeat; z-index: 0;"

View File

@@ -213,7 +213,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
{nil, nil}
Map.get(image, :image_id) ->
{"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
{"/image_cache/#{image.image_id}", ProductImage.source_width(image)}
Map.get(image, :src) ->
{Map.get(image, :src), ProductImage.source_width(image)}

View File

@@ -4,168 +4,6 @@ defmodule SimpleshopThemeWeb.ImageController do
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.
@@ -208,18 +46,4 @@ defmodule SimpleshopThemeWeb.ImageController do
"#" <> 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

View File

@@ -126,7 +126,7 @@ defmodule SimpleshopThemeWeb.Admin.ProductShow do
class="aspect-square rounded bg-base-200 overflow-hidden"
>
<img
src={ProductImage.display_url(image, 400)}
src={ProductImage.url(image, 400)}
alt={image.alt || @product.title}
class="w-full h-full object-cover"
loading="lazy"

View File

@@ -212,7 +212,7 @@ defmodule SimpleshopThemeWeb.Admin.Products do
url =
if image do
ProductImage.display_url(image, 80)
ProductImage.thumbnail_url(image)
end
alt = (image && image.alt) || assigns.product.title

View File

@@ -472,7 +472,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
|> case do
[] -> []

View File

@@ -159,7 +159,7 @@
<%= if @logo_image do %>
<div class="relative w-16 h-10 bg-base-100 border border-base-300 rounded-lg flex items-center justify-center overflow-hidden">
<img
src={"/images/#{@logo_image.id}"}
src={"/image_cache/#{@logo_image.id}.webp"}
alt="Current logo"
class="max-w-full max-h-full object-contain"
/>
@@ -299,7 +299,7 @@
<%= if @header_image do %>
<div class="relative w-full h-[60px] bg-base-100 border border-base-300 rounded-lg mt-2 overflow-hidden">
<img
src={"/images/#{@header_image.id}"}
src={"/image_cache/#{@header_image.id}.webp"}
alt="Current header background"
class="w-full h-full object-cover"
/>

View File

@@ -20,6 +20,11 @@ defmodule SimpleshopThemeWeb.Router do
plug :accepts, ["json"]
end
# Lightweight pipeline for SVG recoloring — no session, CSRF, auth, or layout
pipeline :image do
plug :put_secure_browser_headers
end
pipeline :printify_webhook do
plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook
end
@@ -88,13 +93,10 @@ defmodule SimpleshopThemeWeb.Router do
post "/cart", CartController, :update
end
# Image serving routes (public, no auth required)
# SVG recoloring (dynamic — can't be pre-generated to disk)
scope "/images", SimpleshopThemeWeb do
pipe_through :browser
pipe_through :image
get "/:id", ImageController, :show
get "/:id/thumbnail", ImageController, :thumbnail
get "/:id/variant/:width", ImageController, :variant
get "/:id/recolored/:color", ImageController, :recolored_svg
end