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:
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
|
||||
Reference in New Issue
Block a user