Route all search modal open/close through the JS hook via custom DOM events so the _closing flag is always correctly managed. Prevents the modal flashing back after Escape when a search response is in flight. Add If-None-Match / 304 Not Modified handling to the image controller so browsers don't re-download images on revalidation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
6.7 KiB
Elixir
226 lines
6.7 KiB
Elixir
defmodule SimpleshopThemeWeb.ImageController do
|
|
use SimpleshopThemeWeb, :controller
|
|
|
|
alias SimpleshopTheme.Media
|
|
alias SimpleshopTheme.Media.SVGRecolorer
|
|
|
|
@doc """
|
|
Serves an image from the database by ID.
|
|
|
|
Images are served with aggressive caching headers since they are
|
|
immutable once uploaded.
|
|
"""
|
|
def show(conn, %{"id" => id}) do
|
|
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.
|
|
|
|
The color should be a hex color code (with or without the leading #).
|
|
Only works with SVG images.
|
|
"""
|
|
def recolored_svg(conn, %{"id" => id, "color" => color}) do
|
|
clean_color = normalize_color(color)
|
|
|
|
with true <- SVGRecolorer.valid_hex_color?(clean_color),
|
|
%{is_svg: true, svg_content: svg} when not is_nil(svg) <- Media.get_image(id) do
|
|
recolored = SVGRecolorer.recolor(svg, clean_color)
|
|
|
|
conn
|
|
|> put_resp_content_type("image/svg+xml")
|
|
|> put_resp_header("cache-control", "public, max-age=3600")
|
|
|> put_resp_header("etag", ~s("#{id}-#{clean_color}"))
|
|
|> send_resp(200, recolored)
|
|
else
|
|
false ->
|
|
send_resp(conn, 400, "Invalid color format")
|
|
|
|
nil ->
|
|
send_resp(conn, 404, "Image not found")
|
|
|
|
%{is_svg: false} ->
|
|
send_resp(conn, 400, "Image is not an SVG")
|
|
|
|
%{svg_content: nil} ->
|
|
send_resp(conn, 400, "SVG content not available")
|
|
end
|
|
end
|
|
|
|
defp normalize_color(color) do
|
|
color = String.trim(color)
|
|
|
|
if String.starts_with?(color, "#") do
|
|
color
|
|
else
|
|
"#" <> color
|
|
end
|
|
end
|
|
|
|
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
|