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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
[] -> []
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user