add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
All checks were successful
deploy / deploy (push) Successful in 3m30s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
357
lib/berrypod/redirects.ex
Normal 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
|
||||
38
lib/berrypod/redirects/broken_url.ex
Normal file
38
lib/berrypod/redirects/broken_url.ex
Normal 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
|
||||
31
lib/berrypod/redirects/redirect.ex
Normal file
31
lib/berrypod/redirects/redirect.ex
Normal 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
|
||||
20
lib/berrypod/workers/redirect_pruner_worker.ex
Normal file
20
lib/berrypod/workers/redirect_pruner_worker.ex
Normal 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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
279
lib/berrypod_web/live/admin/redirects.ex
Normal file
279
lib/berrypod_web/live/admin/redirects.ex
Normal 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
|
||||
34
lib/berrypod_web/plugs/broken_url_tracker.ex
Normal file
34
lib/berrypod_web/plugs/broken_url_tracker.ex
Normal 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
|
||||
66
lib/berrypod_web/plugs/redirects.ex
Normal file
66
lib/berrypod_web/plugs/redirects.ex
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user