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

@ -40,3 +40,6 @@ config :phoenix_live_view,
# Use inline testing mode for Oban # Use inline testing mode for Oban
config :simpleshop_theme, Oban, testing: :inline config :simpleshop_theme, Oban, testing: :inline
# Isolate image cache so test cleanup doesn't wipe the dev cache
config :simpleshop_theme, :image_cache_dir, Path.expand("../tmp/test_image_cache", __DIR__)

View File

@ -8,6 +8,7 @@ defmodule SimpleshopTheme.Cart do
""" """
alias SimpleshopTheme.Products alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProductImage
@session_key "cart" @session_key "cart"
@ -148,17 +149,9 @@ defmodule SimpleshopTheme.Cart do
defp format_variant_options(_), do: nil defp format_variant_options(_), do: nil
defp variant_image_url(product) do defp variant_image_url(product) do
# Get first image from preloaded images
case product.images do case product.images do
[first | _] -> [first | _] -> ProductImage.url(first, 400)
if first.image_id do _ -> nil
"/images/#{first.image_id}/variant/400.webp"
else
first.src
end
_ ->
nil
end end
end end

View File

@ -9,16 +9,26 @@ defmodule SimpleshopTheme.Images.Optimizer do
alias SimpleshopTheme.Media.Image, as: ImageSchema alias SimpleshopTheme.Media.Image, as: ImageSchema
@all_widths [400, 800, 1200] @all_widths [400, 800, 1200]
# JPEG is generated on-demand to save ~50% disk space @pregenerated_formats [:avif, :webp, :jpg]
# Only affects <5% of users (legacy browsers without AVIF/WebP support)
@pregenerated_formats [:avif, :webp]
@thumb_size 200 @thumb_size 200
@max_stored_width 2000 @max_stored_width 2000
@storage_quality 90 @storage_quality 90
def cache_dir, do: Application.app_dir(:simpleshop_theme, "priv/static/image_cache") def cache_dir do
Application.get_env(:simpleshop_theme, :image_cache_dir) ||
Application.app_dir(:simpleshop_theme, "priv/static/image_cache")
end
def all_widths, do: @all_widths def all_widths, do: @all_widths
@doc """
Returns the expected disk path for a variant file.
Used to check the cache without loading the BLOB from the database.
"""
def variant_path(image_id, width, format) do
Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
end
@doc """ @doc """
Convert uploaded image to optimized WebP for storage. Convert uploaded image to optimized WebP for storage.
Images larger than #{@max_stored_width}px are resized down. Images larger than #{@max_stored_width}px are resized down.
@ -73,6 +83,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
%{data: data, source_width: width} = image -> %{data: data, source_width: width} = image ->
File.mkdir_p!(cache_dir()) File.mkdir_p!(cache_dir())
# Write source WebP to disk so it can be served by Plug.Static
source_path = Path.join(cache_dir(), "#{image_id}.webp")
unless File.exists?(source_path), do: File.write!(source_path, data)
with {:ok, vips_image} <- Image.from_binary(data) do with {:ok, vips_image} <- Image.from_binary(data) do
widths = applicable_widths(width) widths = applicable_widths(width)
@ -135,10 +149,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
@doc """ @doc """
Check if disk variants exist for an image. Check if disk variants exist for an image.
Only checks pre-generated formats (AVIF, WebP). JPEG is generated on-demand.
""" """
def disk_variants_exist?(image_id, source_width) do def disk_variants_exist?(image_id, source_width) do
widths = applicable_widths(source_width) widths = applicable_widths(source_width)
source = File.exists?(Path.join(cache_dir(), "#{image_id}.webp"))
thumb = File.exists?(Path.join(cache_dir(), "#{image_id}-thumb.jpg")) thumb = File.exists?(Path.join(cache_dir(), "#{image_id}-thumb.jpg"))
variants = variants =
@ -148,34 +162,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
end) end)
end) end)
thumb and variants source and thumb and variants
end
@doc """
Generate a variant on-demand. Returns path to generated file.
Supports all formats: :avif, :webp, :jpg
Used as fallback if cache files are deleted, and for JPEG legacy browser support.
"""
def generate_variant_on_demand(image_data, image_id, width, format)
when is_binary(image_data) and format in [:avif, :webp, :jpg] do
path = Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
if File.exists?(path) do
{:ok, path}
else
File.mkdir_p!(cache_dir())
with {:ok, vips_image} <- Image.from_binary(image_data),
{:ok, resized} <- Image.thumbnail(vips_image, width),
{:ok, _} <- write_format(resized, path, format) do
{:ok, path}
end
end
end
# Backward compatibility alias
def generate_jpeg_on_demand(image_data, image_id, width) do
generate_variant_on_demand(image_data, image_id, width, :jpg)
end end
@doc """ @doc """

View File

@ -51,37 +51,56 @@ defmodule SimpleshopTheme.Images.VariantCache do
end end
defp ensure_database_image_variants do defp ensure_database_image_variants do
incomplete = # Only load IDs and source_width for the disk check — avoids loading BLOBs
incomplete_ids =
ImageSchema ImageSchema
|> where([i], i.variants_status != "complete" or is_nil(i.variants_status)) |> where([i], i.variants_status != "complete" or is_nil(i.variants_status))
|> where([i], i.is_svg == false) |> where([i], i.is_svg == false)
|> select([i], {i.id, i.source_width})
|> Repo.all() |> Repo.all()
complete_missing = complete_missing_ids =
ImageSchema ImageSchema
|> where([i], i.variants_status == "complete") |> where([i], i.variants_status == "complete")
|> where([i], i.is_svg == false) |> where([i], i.is_svg == false)
|> where([i], not is_nil(i.source_width)) |> where([i], not is_nil(i.source_width))
|> select([i], {i.id, i.source_width})
|> Repo.all() |> Repo.all()
|> Enum.reject(fn img -> |> Enum.reject(fn {id, source_width} ->
Optimizer.disk_variants_exist?(img.id, img.source_width) Optimizer.disk_variants_exist?(id, source_width)
end) end)
to_process = incomplete ++ complete_missing to_process = incomplete_ids ++ complete_missing_ids
if to_process == [] do if to_process == [] do
Logger.info("[VariantCache] All database image variants up to date") Logger.info("[VariantCache] All database image variants up to date")
else else
Logger.info( Logger.info(
"[VariantCache] Enqueueing #{length(to_process)} database images for processing" "[VariantCache] Processing #{length(to_process)} images with missing variants..."
) )
Enum.each(to_process, fn image -> # Process directly instead of round-tripping through Oban — more reliable at startup
image to_process
|> ImageSchema.changeset(%{variants_status: "pending"}) |> Task.async_stream(
|> Repo.update!() fn {id, _source_width} ->
case Optimizer.process_for_image(id) do
{:ok, _} ->
:ok
OptimizeWorker.enqueue(image.id) {:error, reason} ->
Logger.warning("[VariantCache] Failed to process #{id}: #{inspect(reason)}")
end
end,
max_concurrency: 4,
timeout: :timer.seconds(30),
on_timeout: :kill_task
)
|> Enum.count(fn
{:ok, :ok} -> true
_ -> false
end)
|> then(fn count ->
Logger.info("[VariantCache] Processed #{count}/#{length(to_process)} image variants")
end) end)
end end
end end
@ -111,7 +130,12 @@ defmodule SimpleshopTheme.Images.VariantCache do
defp mockup_variants_exist?(source_path) do defp mockup_variants_exist?(source_path) do
basename = Path.basename(source_path) |> Path.rootname() basename = Path.basename(source_path) |> Path.rootname()
dir = Path.dirname(source_path) dir = Path.dirname(source_path)
File.exists?(Path.join(dir, "#{basename}-800.webp"))
expected =
["#{basename}-thumb.jpg"] ++
for w <- [400, 800, 1200], ext <- ["avif", "webp", "jpg"], do: "#{basename}-#{w}.#{ext}"
Enum.all?(expected, &File.exists?(Path.join(dir, &1)))
end end
defp ensure_product_image_downloads do defp ensure_product_image_downloads do

View File

@ -170,31 +170,4 @@ defmodule SimpleshopTheme.Media do
def list_images_by_type(type) do def list_images_by_type(type) do
Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at]) Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
end end
@doc """
Gets the path to a thumbnail on disk.
## Examples
iex> get_thumbnail_path("abc123-def456")
"priv/static/image_cache/abc123-def456-thumb.jpg"
"""
def get_thumbnail_path(image_id) do
Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg")
end
@doc """
Gets the path to a variant on disk.
## Examples
iex> get_variant_path("abc123-def456", 800, :webp)
"priv/static/image_cache/abc123-def456-800.webp"
"""
def get_variant_path(image_id, width, format) do
ext = Atom.to_string(format)
Path.join(Optimizer.cache_dir(), "#{image_id}-#{width}.#{ext}")
end
end end

View File

@ -183,7 +183,7 @@ defmodule SimpleshopTheme.Products do
) )
|> Repo.one() |> Repo.one()
|> case do |> case do
{id, _src} when not is_nil(id) -> "/images/#{id}/variant/400.webp" {id, _src} when not is_nil(id) -> "/image_cache/#{id}-400.webp"
{_, src} when is_binary(src) -> src {_, src} when is_binary(src) -> src
_ -> nil _ -> nil
end end

View File

@ -40,30 +40,28 @@ defmodule SimpleshopTheme.Products.ProductImage do
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@doc """ @doc """
Returns the display URL for a product image at the given size. Returns the URL for a product image variant at the given width.
Prefers local image_id (responsive optimized), falls back to CDN src. Prefers local image_id (static file), falls back to CDN src.
Handles mockup URL patterns that need size suffixes.
""" """
def display_url(image, size \\ 800) def url(image, width \\ 800)
def display_url(%{image_id: id}, size) when not is_nil(id), def url(%{image_id: id}, width) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp" do: "/image_cache/#{id}-#{width}.webp"
def display_url(%{src: src}, _size) when is_binary(src), do: src def url(%{src: "/mockups/" <> _ = src}, width), do: "#{src}-#{width}.webp"
def display_url(_, _), do: nil def url(%{src: src}, _width) when is_binary(src), do: src
def url(_, _), do: nil
@doc """ @doc """
Returns a fully resolved URL for an image at the given size. Returns the URL for the pre-generated 200px thumbnail.
Unlike `display_url/2`, handles mockup URL patterns that need size suffixes. Used for small previews (admin lists, cart items).
Use for `<img>` tags where the URL must resolve to an actual file.
""" """
def direct_url(image, size \\ 800) def thumbnail_url(%{image_id: id}) when not is_nil(id),
do: "/image_cache/#{id}-thumb.jpg"
def direct_url(%{image_id: id}, size) when not is_nil(id), def thumbnail_url(%{src: src}) when is_binary(src), do: src
do: "/images/#{id}/variant/#{size}.webp" def thumbnail_url(_), do: nil
def direct_url(%{src: "/mockups/" <> _ = src}, size), do: "#{src}-#{size}.webp"
def direct_url(%{src: src}, _size) when is_binary(src), do: src
def direct_url(_, _), do: nil
@doc """ @doc """
Returns the source width from the linked Media.Image, if preloaded. Returns the source width from the linked Media.Image, if preloaded.

View File

@ -414,7 +414,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
end end
defp cart_item_image(product) do defp cart_item_image(product) do
ProductImage.direct_url(Product.primary_image(product), 400) ProductImage.url(Product.primary_image(product), 400)
end end
# Shared delivery line used by both cart_drawer and order_summary. # 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) available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available) 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 =
assigns assigns
|> assign(:available_widths, available) |> assign(:available_widths, available)
|> assign(:default_width, default_width) |> assign(:default_width, default_width)
|> assign(:separator, separator)
~H""" ~H"""
<picture> <picture>
@ -1069,7 +1064,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
sizes={@sizes} sizes={@sizes}
/> />
<img <img
src={"#{@src}#{@separator}#{@default_width}.jpg"} src={"#{@src}-#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")} srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes} sizes={@sizes}
alt={@alt} alt={@alt}
@ -1163,12 +1158,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
end end
defp build_srcset(base, widths, format) do 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 widths
|> Enum.sort() |> Enum.sort()
|> Enum.map_join(", ", &"#{base}#{separator}#{&1}.#{format} #{&1}w") |> Enum.map_join(", ", &"#{base}-#{&1}.#{format} #{&1}w")
end end
end end

View File

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

View File

@ -213,7 +213,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
{nil, nil} {nil, nil}
Map.get(image, :image_id) -> 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) ->
{Map.get(image, :src), ProductImage.source_width(image)} {Map.get(image, :src), ProductImage.source_width(image)}

View File

@ -4,168 +4,6 @@ defmodule SimpleshopThemeWeb.ImageController do
alias SimpleshopTheme.Media alias SimpleshopTheme.Media
alias SimpleshopTheme.Media.SVGRecolorer 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 """ @doc """
Serves an SVG image recolored with the specified color. Serves an SVG image recolored with the specified color.
@ -208,18 +46,4 @@ defmodule SimpleshopThemeWeb.ImageController do
"#" <> color "#" <> color
end end
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 end

View File

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

View File

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

View File

@ -472,7 +472,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
(Map.get(product, :images) || []) (Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position) |> 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) |> Enum.reject(&is_nil/1)
|> case do |> case do
[] -> [] [] -> []

View File

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

View File

@ -20,6 +20,11 @@ defmodule SimpleshopThemeWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end 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 pipeline :printify_webhook do
plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook
end end
@ -88,13 +93,10 @@ defmodule SimpleshopThemeWeb.Router do
post "/cart", CartController, :update post "/cart", CartController, :update
end end
# Image serving routes (public, no auth required) # SVG recoloring (dynamic — can't be pre-generated to disk)
scope "/images", SimpleshopThemeWeb do 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 get "/:id/recolored/:color", ImageController, :recolored_svg
end end

View File

@ -17,13 +17,9 @@ defmodule SimpleshopTheme.Images.OptimizeWorkerTest do
assert :ok = perform_job(OptimizeWorker, %{image_id: image.id}) assert :ok = perform_job(OptimizeWorker, %{image_id: image.id})
# Verify pre-generated variants were created (AVIF and WebP only, not JPEG) for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do
assert File.exists?(cache_path(image.id, w, fmt)) assert File.exists?(cache_path(image.id, w, fmt))
end end
# JPEG is generated on-demand, not pre-generated
refute File.exists?(cache_path(image.id, 400, :jpg))
end end
test "cancels for missing image" do test "cancels for missing image" do

View File

@ -62,16 +62,14 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id) assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
# Only AVIF and WebP are pre-generated (JPEG is on-demand) # Source WebP for Plug.Static serving
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do assert File.exists?(Path.join(Optimizer.cache_dir(), "#{image.id}.webp"))
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
assert File.exists?(cache_path(image.id, w, fmt)), assert File.exists?(cache_path(image.id, w, fmt)),
"Missing #{w}.#{fmt}" "Missing #{w}.#{fmt}"
end end
# JPEG should NOT be pre-generated
refute File.exists?(cache_path(image.id, 400, :jpg))
# Thumbnail is still pre-generated as JPEG
assert File.exists?(cache_path(image.id, "thumb", :jpg)) assert File.exists?(cache_path(image.id, "thumb", :jpg))
end end
@ -138,36 +136,4 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
refute Optimizer.disk_variants_exist?(image.id, 1200) refute Optimizer.disk_variants_exist?(image.id, 1200)
end end
end end
describe "generate_jpeg_on_demand/3" do
test "generates JPEG variant and caches to disk" do
image = image_fixture(%{source_width: 1200, source_height: 800})
# JPEG shouldn't exist yet
refute File.exists?(cache_path(image.id, 400, :jpg))
# Generate on-demand
{:ok, path} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 400)
assert File.exists?(path)
assert path == cache_path(image.id, 400, :jpg)
end
test "returns cached path if JPEG already exists" do
image = image_fixture(%{source_width: 1200, source_height: 800})
# Generate first time
{:ok, path1} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800)
{:ok, %{mtime: mtime1}} = File.stat(path1)
Process.sleep(1100)
# Generate second time - should return cached
{:ok, path2} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800)
{:ok, %{mtime: mtime2}} = File.stat(path2)
assert path1 == path2
assert mtime1 == mtime2, "File was regenerated instead of cached"
end
end
end end

View File

@ -67,28 +67,49 @@ defmodule SimpleshopTheme.Products.ProductImageTest do
# Display helpers # Display helpers
# ============================================================================= # =============================================================================
describe "display_url/2" do describe "url/2" do
test "prefers local image_id over src" do test "prefers local image_id over src" do
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"} image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
assert ProductImage.display_url(image) == "/images/abc-123/variant/800.webp" assert ProductImage.url(image) == "/image_cache/abc-123-800.webp"
end end
test "accepts custom size" do test "accepts custom width" do
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"} image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
assert ProductImage.display_url(image, 400) == "/images/abc-123/variant/400.webp" assert ProductImage.url(image, 400) == "/image_cache/abc-123-400.webp"
end
test "handles mockup URLs with size suffix" do
image = %{image_id: nil, src: "/mockups/product-1"}
assert ProductImage.url(image, 800) == "/mockups/product-1-800.webp"
end end
test "falls back to src when no image_id" do test "falls back to src when no image_id" do
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"} image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
assert ProductImage.display_url(image) == "https://cdn.example.com/img.jpg" assert ProductImage.url(image) == "https://cdn.example.com/img.jpg"
end end
test "returns nil when neither image_id nor src" do test "returns nil when neither image_id nor src" do
assert ProductImage.display_url(%{image_id: nil, src: nil}) == nil assert ProductImage.url(%{image_id: nil, src: nil}) == nil
end end
test "returns nil for nil input" do test "returns nil for nil input" do
assert ProductImage.display_url(nil) == nil assert ProductImage.url(nil) == nil
end
end
describe "thumbnail_url/1" do
test "returns thumb path for local image" do
image = %{image_id: "abc-123"}
assert ProductImage.thumbnail_url(image) == "/image_cache/abc-123-thumb.jpg"
end
test "falls back to src when no image_id" do
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
assert ProductImage.thumbnail_url(image) == "https://cdn.example.com/img.jpg"
end
test "returns nil for nil input" do
assert ProductImage.thumbnail_url(nil) == nil
end end
end end

View File

@ -3,81 +3,9 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
alias SimpleshopTheme.Media alias SimpleshopTheme.Media
# Minimal valid PNG (1x1 transparent pixel)
@png_binary <<137, 80, 78, 71, 13, 10, 26, 10>>
@svg_content ~s(<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#000000" r="10"/></svg>) @svg_content ~s(<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#000000" r="10"/></svg>)
@sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg") @sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg")
describe "show/2" do
test "returns 404 for non-existent image", %{conn: conn} do
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}")
assert response(conn, 404) =~ "Image not found"
end
test "serves image with proper content type and caching headers", %{conn: conn} do
# Upload a real JPEG which gets converted to WebP
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.jpg",
content_type: "image/jpeg",
file_size: byte_size(@sample_jpeg),
data: @sample_jpeg
})
conn = get(conn, ~p"/images/#{image.id}")
assert response(conn, 200)
# Image is converted to WebP for storage
assert get_resp_header(conn, "content-type") == ["image/webp; charset=utf-8"]
assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
assert get_resp_header(conn, "etag") == [~s("#{image.id}")]
end
end
describe "thumbnail/2" do
test "returns 404 for non-existent image", %{conn: conn} do
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/thumbnail")
assert response(conn, 404) =~ "Image not found"
end
test "generates thumbnail on-demand and serves as JPEG", %{conn: conn} do
# Upload a real image to test thumbnail generation
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.jpg",
content_type: "image/jpeg",
file_size: byte_size(@sample_jpeg),
data: @sample_jpeg
})
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
assert response(conn, 200)
# Thumbnail is served as JPEG
assert get_resp_header(conn, "content-type") == ["image/jpeg; charset=utf-8"]
assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
end
test "falls back to full image when image data is invalid", %{conn: conn} do
# This uses an invalid PNG header that can't be processed
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.png",
content_type: "image/png",
file_size: byte_size(@png_binary),
data: @png_binary
})
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
# Falls back to WebP since that's what we tried to convert to
assert response(conn, 200)
end
end
describe "recolored_svg/2" do describe "recolored_svg/2" do
test "returns 404 for non-existent image", %{conn: conn} do test "returns 404 for non-existent image", %{conn: conn} do
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/recolored/ff6600") conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/recolored/ff6600")
@ -88,10 +16,10 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
{:ok, image} = {:ok, image} =
Media.upload_image(%{ Media.upload_image(%{
image_type: "logo", image_type: "logo",
filename: "test.png", filename: "test.jpg",
content_type: "image/png", content_type: "image/jpeg",
file_size: byte_size(@png_binary), file_size: byte_size(@sample_jpeg),
data: @png_binary data: @sample_jpeg
}) })
conn = get(conn, ~p"/images/#{image.id}/recolored/ff6600") conn = get(conn, ~p"/images/#{image.id}/recolored/ff6600")