add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
All checks were successful
deploy / deploy (push) Successful in 3m30s

Redirects context with redirect/broken_url schemas, chain flattening,
ETS cache for fast lookups in the request pipeline. BrokenUrlTracker
plug logs 404s. Auto-redirect on product slug change via upsert_product
hook. Admin redirects page with active/broken tabs, manual create form.
RedirectPrunerWorker cleans up old broken URLs. 1227 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-26 14:14:14 +00:00
parent 23e95a3de6
commit 6e57af82fc
21 changed files with 1493 additions and 24 deletions

View File

@@ -7,6 +7,10 @@ defmodule Berrypod.Application do
@impl true
def start(_type, _args) do
# Create ETS table here so the supervisor process owns it (lives forever).
# The Task below only warms it with data from the DB.
Berrypod.Redirects.create_table()
children = [
BerrypodWeb.Telemetry,
Berrypod.Repo,
@@ -20,6 +24,8 @@ defmodule Berrypod.Application do
Supervisor.child_spec({Task, &Berrypod.Mailer.load_config/0}, id: :load_email_config),
{DNSCluster, query: Application.get_env(:berrypod, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Berrypod.PubSub},
# Warm redirect cache from DB (table already created above)
Supervisor.child_spec({Task, &Berrypod.Redirects.warm_cache/0}, id: :redirect_cache),
# Background job processing
{Oban, Application.fetch_env!(:berrypod, Oban)},
# Analytics: daily-rotating salt and ETS event buffer

View File

@@ -387,6 +387,20 @@ defmodule Berrypod.Products do
Deletes a product.
"""
def delete_product(%Product{} = product) do
# Create a redirect before deletion so the old URL doesn't 404
target =
if product.category do
"/collections/#{Slug.slugify(product.category)}"
else
"/"
end
Berrypod.Redirects.create_auto(%{
from_path: "/products/#{product.slug}",
to_path: target,
source: "auto_product_deleted"
})
Repo.delete(product)
end
@@ -419,9 +433,22 @@ defmodule Berrypod.Products do
{:ok, product, :unchanged}
product ->
old_slug = product.slug
case update_product(product, attrs) do
{:ok, product} -> {:ok, product, :updated}
error -> error
{:ok, updated} ->
if old_slug != updated.slug do
Berrypod.Redirects.create_auto(%{
from_path: "/products/#{old_slug}",
to_path: "/products/#{updated.slug}",
source: "auto_slug_change"
})
end
{:ok, updated, :updated}
error ->
error
end
end
end

View File

@@ -157,9 +157,22 @@ defmodule Berrypod.Products.Product do
def compute_checksum(_), do: nil
defp generate_slug_if_missing(changeset) do
case get_field(changeset, :slug) do
nil ->
title = get_change(changeset, :title) || get_field(changeset, :title)
slug_change = get_change(changeset, :slug)
title_change = get_change(changeset, :title)
current_slug = get_field(changeset, :slug)
cond do
# Explicit slug provided — use it as-is
slug_change != nil ->
changeset
# Title changed — regenerate slug to match
title_change != nil ->
put_change(changeset, :slug, Slug.slugify(title_change))
# No slug yet — generate from title
current_slug == nil ->
title = get_field(changeset, :title)
if title do
put_change(changeset, :slug, Slug.slugify(title))
@@ -167,7 +180,8 @@ defmodule Berrypod.Products.Product do
changeset
end
_ ->
# Slug exists and title didn't change — keep it
true ->
changeset
end
end

357
lib/berrypod/redirects.ex Normal file
View File

@@ -0,0 +1,357 @@
defmodule Berrypod.Redirects do
@moduledoc """
Manages URL redirects and broken URL tracking.
Redirects are cached in ETS for fast lookup on every request.
The cache is warmed on application start and invalidated on
any redirect create/update/delete.
"""
import Ecto.Query
alias Berrypod.Repo
alias Berrypod.Redirects.{Redirect, BrokenUrl}
@table :redirects_cache
@pubsub_topic "redirects"
def subscribe do
Phoenix.PubSub.subscribe(Berrypod.PubSub, @pubsub_topic)
end
defp broadcast(message) do
Phoenix.PubSub.broadcast(Berrypod.PubSub, @pubsub_topic, message)
end
# ── ETS cache ──
def start_cache do
create_table()
warm_cache()
end
def create_table do
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
end
@table
end
def warm_cache do
redirects =
Repo.all(from r in Redirect, select: {r.from_path, {r.to_path, r.status_code, r.id}})
for {from_path, value} <- redirects do
:ets.insert(@table, {from_path, value})
end
:ok
end
defp invalidate_cache(from_path) do
:ets.delete(@table, from_path)
end
defp put_cache(from_path, to_path, status_code, id) do
:ets.insert(@table, {from_path, {to_path, status_code, id}})
end
# ── Lookup ──
@doc """
Looks up a redirect by path. Checks ETS cache first, falls back to DB.
"""
def lookup(path) do
case :ets.lookup(@table, path) do
[{^path, {to_path, status_code, id}}] ->
{:ok, %{to_path: to_path, status_code: status_code, id: id}}
[] ->
case Repo.one(from r in Redirect, where: r.from_path == ^path) do
nil ->
:not_found
redirect ->
put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id)
{:ok,
%{to_path: redirect.to_path, status_code: redirect.status_code, id: redirect.id}}
end
end
end
# ── Create ──
@doc """
Creates an automatic redirect (from sync events).
Flattens redirect chains: if the new redirect's `to_path` is itself
a `from_path` in an existing redirect, follows it to the final destination.
Also updates any existing redirects that point to the new `from_path`
to point directly to the final destination instead.
Uses `on_conflict: :nothing` so repeated sync calls are safe.
"""
def create_auto(attrs) do
to_path = resolve_chain(attrs[:to_path] || attrs["to_path"])
attrs = Map.put(attrs, :to_path, to_path)
from_path = attrs[:from_path] || attrs["from_path"]
# Flatten any existing redirects that point to our from_path
flatten_incoming(from_path, to_path)
changeset = Redirect.changeset(%Redirect{}, attrs)
case Repo.insert(changeset, on_conflict: :nothing, conflict_target: :from_path) do
{:ok, redirect} ->
put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id)
broadcast({:redirects_changed, :created})
{:ok, redirect}
error ->
error
end
end
@doc """
Creates a manual redirect (from admin UI).
"""
def create_manual(attrs) do
attrs = Map.put(attrs, :source, "admin")
to_path = resolve_chain(attrs[:to_path] || attrs["to_path"])
attrs = Map.put(attrs, :to_path, to_path)
from_path = attrs[:from_path] || attrs["from_path"]
flatten_incoming(from_path, to_path)
changeset = Redirect.changeset(%Redirect{}, attrs)
case Repo.insert(changeset) do
{:ok, redirect} ->
put_cache(redirect.from_path, redirect.to_path, redirect.status_code, redirect.id)
broadcast({:redirects_changed, :created})
{:ok, redirect}
error ->
error
end
end
# Follow redirect chains to find the final destination
defp resolve_chain(path, seen \\ MapSet.new()) do
if MapSet.member?(seen, path) do
# Circular — stop here
path
else
case Repo.one(from r in Redirect, where: r.from_path == ^path, select: r.to_path) do
nil -> path
next -> resolve_chain(next, MapSet.put(seen, path))
end
end
end
# Update any redirects whose to_path matches old_to to point to new_to instead
defp flatten_incoming(old_to, new_to) do
from(r in Redirect, where: r.to_path == ^old_to)
|> Repo.update_all(set: [to_path: new_to])
# Refresh cache for any updated redirects
from(r in Redirect, where: r.to_path == ^new_to)
|> Repo.all()
|> Enum.each(fn r -> put_cache(r.from_path, r.to_path, r.status_code, r.id) end)
end
# ── Update / Delete ──
@doc """
Updates an existing redirect.
"""
def update_redirect(%Redirect{} = redirect, attrs) do
changeset = Redirect.changeset(redirect, attrs)
case Repo.update(changeset) do
{:ok, updated} ->
# Old from_path may have changed
if redirect.from_path != updated.from_path do
invalidate_cache(redirect.from_path)
end
put_cache(updated.from_path, updated.to_path, updated.status_code, updated.id)
{:ok, updated}
error ->
error
end
end
@doc """
Deletes a redirect.
"""
def delete_redirect(%Redirect{} = redirect) do
case Repo.delete(redirect) do
{:ok, deleted} ->
invalidate_cache(deleted.from_path)
broadcast({:redirects_changed, :deleted})
{:ok, deleted}
error ->
error
end
end
@doc """
Increments the hit count for a redirect.
"""
def increment_hit_count(%{id: id}) do
from(r in Redirect, where: r.id == ^id)
|> Repo.update_all(inc: [hit_count: 1])
end
# ── Listing ──
@doc """
Lists all redirects, ordered by most recent first.
"""
def list_redirects do
from(r in Redirect, order_by: [desc: r.inserted_at])
|> Repo.all()
end
@doc """
Gets a single redirect by ID.
"""
def get_redirect!(id), do: Repo.get!(Redirect, id)
# ── Broken URLs ──
@doc """
Records or updates a broken URL entry.
If the path already exists, increments the 404 count and updates last_seen_at.
"""
def record_broken_url(path, prior_hits) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
result =
case Repo.one(from b in BrokenUrl, where: b.path == ^path) do
nil ->
%BrokenUrl{}
|> BrokenUrl.changeset(%{
path: path,
prior_analytics_hits: prior_hits,
first_seen_at: now,
last_seen_at: now
})
|> Repo.insert()
%{status: status} when status in ["ignored", "resolved"] ->
{:ok, :skipped}
existing ->
existing
|> BrokenUrl.changeset(%{
recent_404_count: existing.recent_404_count + 1,
last_seen_at: now
})
|> Repo.update()
end
case result do
{:ok, %BrokenUrl{}} -> broadcast({:broken_urls_changed, path})
_ -> :ok
end
result
end
@doc """
Lists broken URLs, sorted by prior analytics hits (highest impact first).
"""
def list_broken_urls(status \\ "pending") do
from(b in BrokenUrl,
where: b.status == ^status,
order_by: [desc: b.prior_analytics_hits, desc: b.recent_404_count]
)
|> Repo.all()
end
@doc """
Resolves a broken URL by creating a redirect and updating the record.
"""
def resolve_broken_url(%BrokenUrl{} = broken_url, to_path) do
case create_manual(%{from_path: broken_url.path, to_path: to_path}) do
{:ok, redirect} ->
broken_url
|> BrokenUrl.changeset(%{status: "resolved", resolved_redirect_id: redirect.id})
|> Repo.update()
error ->
error
end
end
@doc """
Marks a broken URL as ignored.
"""
def ignore_broken_url(%BrokenUrl{} = broken_url) do
result =
broken_url
|> BrokenUrl.changeset(%{status: "ignored"})
|> Repo.update()
case result do
{:ok, _} -> broadcast({:broken_urls_changed, broken_url.path})
_ -> :ok
end
result
end
@doc """
Marks a broken URL as resolved (e.g. after creating a redirect for it).
"""
def mark_broken_url_resolved(%BrokenUrl{} = broken_url) do
result =
broken_url
|> BrokenUrl.changeset(%{status: "resolved"})
|> Repo.update()
case result do
{:ok, _} -> broadcast({:broken_urls_changed, broken_url.path})
_ -> :ok
end
result
end
@doc """
Gets a broken URL by ID.
"""
def get_broken_url!(id), do: Repo.get!(BrokenUrl, id)
@doc """
Gets a pending broken URL by path, or nil.
"""
def get_broken_url_by_path(path) do
Repo.one(from b in BrokenUrl, where: b.path == ^path and b.status == "pending")
end
# ── Pruning ──
@doc """
Prunes auto-created redirects with zero hits older than the given number of days.
"""
def prune_stale_redirects(max_age_days \\ 90) do
{count, _} =
from(r in Redirect,
where: r.source in ["auto_slug_change", "auto_product_deleted"] and r.hit_count == 0,
where: r.inserted_at < ago(^max_age_days, "day")
)
|> Repo.delete_all()
# Rebuild cache if anything was pruned
if count > 0, do: warm_cache()
{:ok, count}
end
end

View File

@@ -0,0 +1,38 @@
defmodule Berrypod.Redirects.BrokenUrl do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@statuses ~w(pending resolved ignored)
schema "broken_urls" do
field :path, :string
field :prior_analytics_hits, :integer, default: 0
field :recent_404_count, :integer, default: 1
field :first_seen_at, :utc_datetime
field :last_seen_at, :utc_datetime
field :status, :string, default: "pending"
belongs_to :resolved_redirect, Berrypod.Redirects.Redirect
timestamps()
end
def changeset(broken_url, attrs) do
broken_url
|> cast(attrs, [
:path,
:prior_analytics_hits,
:recent_404_count,
:first_seen_at,
:last_seen_at,
:status,
:resolved_redirect_id
])
|> validate_required([:path, :first_seen_at, :last_seen_at])
|> validate_inclusion(:status, @statuses)
|> unique_constraint(:path)
end
end

View File

@@ -0,0 +1,31 @@
defmodule Berrypod.Redirects.Redirect do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@sources ~w(auto_slug_change auto_product_deleted analytics_detected admin)
schema "redirects" do
field :from_path, :string
field :to_path, :string
field :status_code, :integer, default: 301
field :source, :string
field :confidence, :float
field :hit_count, :integer, default: 0
timestamps()
end
def changeset(redirect, attrs) do
redirect
|> cast(attrs, [:from_path, :to_path, :status_code, :source, :confidence])
|> validate_required([:from_path, :to_path, :source])
|> validate_inclusion(:source, @sources)
|> validate_inclusion(:status_code, [301, 302])
|> validate_format(:from_path, ~r"^/", message: "must start with /")
|> validate_format(:to_path, ~r"^/", message: "must start with /")
|> unique_constraint(:from_path)
end
end

View File

@@ -0,0 +1,20 @@
defmodule Berrypod.Workers.RedirectPrunerWorker do
@moduledoc """
Weekly Oban cron job that prunes auto-created redirects
with zero hits older than 90 days.
"""
use Oban.Worker, queue: :default, max_attempts: 1
@impl Oban.Worker
def perform(_job) do
{:ok, count} = Berrypod.Redirects.prune_stale_redirects()
if count > 0 do
require Logger
Logger.info("Pruned #{count} stale redirect(s) with 0 hits")
end
:ok
end
end

View File

@@ -118,6 +118,14 @@
<.icon name="hero-envelope" class="size-5" /> Email
</.link>
</li>
<li>
<.link
navigate={~p"/admin/redirects"}
class={admin_nav_active?(@current_path, "/admin/redirects")}
>
<.icon name="hero-arrow-uturn-right" class="size-5" /> Redirects
</.link>
</li>
</ul>
</nav>

View File

@@ -59,5 +59,6 @@ defmodule BerrypodWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug BerrypodWeb.Router
plug BerrypodWeb.Plugs.Redirects
plug BerrypodWeb.Plugs.BrokenUrlTracker, router: BerrypodWeb.Router
end

View File

@@ -0,0 +1,279 @@
defmodule BerrypodWeb.Admin.Redirects do
use BerrypodWeb, :live_view
alias Berrypod.Redirects
@valid_tabs ~w(redirects broken create)
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Redirects.subscribe()
socket =
socket
|> assign(:page_title, "Redirects")
|> assign(:redirects, Redirects.list_redirects())
|> assign(:broken_urls, Redirects.list_broken_urls())
|> assign(
:form,
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects"
{:noreply, assign(socket, :tab, tab)}
end
@impl true
def handle_info({:redirects_changed, _action}, socket) do
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
end
def handle_info({:broken_urls_changed, _path}, socket) do
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, push_patch(socket, to: ~p"/admin/redirects?#{%{tab: tab}}")}
end
def handle_event("delete_redirect", %{"id" => id}, socket) do
redirect = Redirects.get_redirect!(id)
{:ok, _} = Redirects.delete_redirect(redirect)
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
end
def handle_event("create_redirect", %{"redirect" => params}, socket) do
from_path = params["from_path"]
case Redirects.create_manual(%{
from_path: from_path,
to_path: params["to_path"],
status_code: String.to_integer(params["status_code"])
}) do
{:ok, _redirect} ->
resolve_matching_broken_url(from_path)
socket =
socket
|> assign(
:form,
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
)
|> put_flash(:info, "Redirect created")
|> push_patch(to: ~p"/admin/redirects?#{%{tab: "redirects"}}")
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
def handle_event("ignore_broken_url", %{"id" => id}, socket) do
broken_url = Redirects.get_broken_url!(id)
{:ok, _} = Redirects.ignore_broken_url(broken_url)
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
end
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
socket =
socket
|> assign(
:form,
to_form(%{"from_path" => path, "to_path" => "", "status_code" => "301"}, as: :redirect)
)
|> push_patch(to: ~p"/admin/redirects?#{%{tab: "create"}}")
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<.header>
Redirects
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<.tab_button tab="redirects" label="Active" count={length(@redirects)} active={@tab} />
<.tab_button tab="broken" label="Broken URLs" count={length(@broken_urls)} active={@tab} />
<.tab_button tab="create" label="Create" active={@tab} />
</div>
<%= if @tab == "redirects" do %>
<.redirects_table redirects={@redirects} />
<% end %>
<%= if @tab == "broken" do %>
<.broken_urls_table broken_urls={@broken_urls} />
<% end %>
<%= if @tab == "create" do %>
<.create_form form={@form} />
<% end %>
"""
end
defp redirects_table(assigns) do
~H"""
<%= if @redirects == [] do %>
<p>No redirects yet.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Source</th>
<th>Hits</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for redirect <- @redirects do %>
<tr>
<td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></td>
<td>
<span class={"badge badge-#{source_colour(redirect.source)}"}>
{redirect.source}
</span>
</td>
<td>{redirect.hit_count}</td>
<td>{Calendar.strftime(redirect.inserted_at, "%d %b %Y")}</td>
<td>
<button
phx-click="delete_redirect"
phx-value-id={redirect.id}
data-confirm="Delete this redirect?"
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Delete
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
"""
end
defp broken_urls_table(assigns) do
~H"""
<%= if @broken_urls == [] do %>
<p>No broken URLs detected.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>Path</th>
<th>Prior traffic</th>
<th>404s</th>
<th>First seen</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for broken_url <- @broken_urls do %>
<tr>
<td><code>{broken_url.path}</code></td>
<td>{broken_url.prior_analytics_hits}</td>
<td>{broken_url.recent_404_count}</td>
<td>{Calendar.strftime(broken_url.first_seen_at, "%d %b %Y")}</td>
<td>{Calendar.strftime(broken_url.last_seen_at, "%d %b %Y")}</td>
<td class="flex gap-2">
<button
phx-click="redirect_broken_url"
phx-value-path={broken_url.path}
class="admin-btn admin-btn-sm admin-btn-primary"
>
Redirect
</button>
<button
phx-click="ignore_broken_url"
phx-value-id={broken_url.id}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Ignore
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
"""
end
defp create_form(assigns) do
~H"""
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;">
<.input
field={@form[:from_path]}
label="From path"
placeholder="/products/old-slug"
required
/>
<.input
field={@form[:to_path]}
label="To path"
placeholder="/products/new-slug"
required
/>
<.input
field={@form[:status_code]}
type="select"
label="Status code"
options={[{"301 (permanent)", "301"}, {"302 (temporary)", "302"}]}
/>
<button type="submit" class="admin-btn admin-btn-primary">Create redirect</button>
</.form>
"""
end
defp tab_button(assigns) do
assigns =
assigns
|> assign(:active, assigns.active == assigns.tab)
|> assign_new(:count, fn -> nil end)
~H"""
<button
phx-click="switch_tab"
phx-value-tab={@tab}
class={[
"admin-btn admin-btn-sm",
@active && "admin-btn-primary",
!@active && "admin-btn-ghost"
]}
>
{@label}
<span :if={@count && @count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
</button>
"""
end
defp resolve_matching_broken_url(from_path) do
case Redirects.get_broken_url_by_path(from_path) do
nil -> :ok
broken_url -> Redirects.mark_broken_url_resolved(broken_url)
end
end
defp source_colour("auto_slug_change"), do: "info"
defp source_colour("auto_product_deleted"), do: "warning"
defp source_colour("analytics_detected"), do: "accent"
defp source_colour("admin"), do: "neutral"
defp source_colour(_), do: "neutral"
end

View File

@@ -0,0 +1,34 @@
defmodule BerrypodWeb.Plugs.BrokenUrlTracker do
@moduledoc """
Wraps the router to record 404s in the broken URLs table.
Works in dev mode too — Plug.Debugger intercepts exceptions before
error templates render, so we catch NoRouteError here, record it,
then re-raise so the normal error handling continues.
"""
@behaviour Plug
def init(opts) do
router = Keyword.fetch!(opts, :router)
router_opts = router.init([])
{router, router_opts}
end
def call(conn, {router, router_opts}) do
router.call(conn, router_opts)
rescue
e in Phoenix.Router.NoRouteError ->
unless static_path?(conn.request_path) do
Berrypod.Redirects.record_broken_url(conn.request_path, 0)
end
reraise e, __STACKTRACE__
end
defp static_path?(path) do
String.starts_with?(path, "/assets/") or
String.starts_with?(path, "/images/") or
String.starts_with?(path, "/favicon")
end
end

View File

@@ -0,0 +1,66 @@
defmodule BerrypodWeb.Plugs.Redirects do
@moduledoc """
Plug that handles URL normalisation and custom redirects.
Three concerns in one pass:
1. Trailing slash removal (/products/foo/ → /products/foo)
2. Case normalisation for shop paths (/Products/Foo → /products/foo)
3. Custom redirect lookup from the redirects table (ETS-cached)
All redirects preserve query params.
"""
import Plug.Conn
alias Berrypod.Redirects
# Only case-normalise paths under these prefixes (SEO-relevant shop routes).
# Paths with tokens, API keys, or other case-sensitive segments are excluded.
@lowercase_prefixes ~w(/products /collections /about /delivery /privacy /terms /search /cart /contact)
def init(opts), do: opts
def call(conn, _opts) do
path = conn.request_path
stripped = strip_trailing_slash(path)
cond do
# Trailing slash — redirect to canonical form
stripped != path ->
redirect_to(conn, stripped, 301)
# Case mismatch on a shop path — redirect to lowercase
lowercase_path?(path) and String.downcase(path) != path ->
redirect_to(conn, String.downcase(path), 301)
# Check redirect table (ETS-cached)
:else ->
case Redirects.lookup(path) do
{:ok, redirect} ->
Redirects.increment_hit_count(redirect)
redirect_to(conn, redirect.to_path, redirect.status_code)
:not_found ->
conn
end
end
end
defp lowercase_path?(path) do
Enum.any?(@lowercase_prefixes, &String.starts_with?(String.downcase(path), &1))
end
defp redirect_to(conn, target, status_code) do
location = append_query(target, conn.query_string)
conn
|> put_resp_header("location", location)
|> send_resp(status_code, "")
|> halt()
end
defp strip_trailing_slash("/"), do: "/"
defp strip_trailing_slash(path), do: String.trim_trailing(path, "/")
defp append_query(path, ""), do: path
defp append_query(path, qs), do: "#{path}?#{qs}"
end

View File

@@ -229,6 +229,7 @@ defmodule BerrypodWeb.Router do
live "/providers/:id/edit", Admin.Providers.Form, :edit
live "/settings", Admin.Settings, :index
live "/settings/email", Admin.EmailSettings, :index
live "/redirects", Admin.Redirects, :index
end
# Theme editor: admin root layout but full-screen (no sidebar)