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:
parent
81e94d0d65
commit
bb358f890b
@ -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__)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;"
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
[] -> []
|
[] -> []
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user