Packages

phoenix_kit

1.7.81
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules publishing web controller fallback.ex
Raw

lib/modules/publishing/web/controller/fallback.ex

defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do
@moduledoc """
404 fallback handling for the publishing controller.
Implements a smart fallback chain that attempts to redirect users
to related content when the requested resource is not found:
- Posts in other languages
- Other posts on the same date
- Group listing page
"""
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Web.Controller.Language
alias PhoenixKit.Modules.Publishing.Web.Controller.Listing
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
# ============================================================================
# Main Entry Point
# ============================================================================
@doc """
Handles 404 not found responses with smart fallback.
"""
def handle_not_found(conn, reason) do
# Try to fall back to nearest valid parent in the breadcrumb chain
case attempt_breadcrumb_fallback(conn, reason) do
{:ok, redirect_path} ->
{:redirect_with_flash, redirect_path,
gettext("The page you requested was not found. Showing closest match.")}
:no_fallback ->
{:render_404}
end
end
# ============================================================================
# Breadcrumb Fallback Logic
# ============================================================================
defp attempt_breadcrumb_fallback(conn, reason) do
language = conn.assigns[:current_language] || "en"
group_slug = conn.params["group"]
path = conn.params["path"] || []
# Build full path including group slug for proper fallback handling
# Route params are: %{"group" => "date", "path" => ["2025-12-09", "15:02"]}
# We need: ["date", "2025-12-09", "15:02"] for pattern matching
full_path = if group_slug, do: [group_slug | path], else: path
handle_fallback_case(reason, full_path, language)
end
# ============================================================================
# Fallback Case Handlers
# ============================================================================
# Post not found (trashed/deleted) — go straight to group listing, don't try other posts
defp handle_fallback_case(:not_found, [group_slug | _], language) do
if group_exists?(group_slug) do
{:ok, PublishingHTML.group_listing_path(language, group_slug)}
else
fallback_to_default_group(language)
end
end
# Slug mode posts (2-element path) - try other languages, then group listing
defp handle_fallback_case(reason, [group_slug, post_slug], language)
when reason in [:post_not_found, :unpublished, :version_access_disabled] do
fallback_to_default_language(group_slug, post_slug, language)
end
# Timestamp mode posts (3-element path) - try other languages, then group listing
defp handle_fallback_case(reason, [group_slug, date, time], language)
when reason in [:post_not_found, :unpublished, :version_access_disabled] do
fallback_timestamp_to_other_language(group_slug, date, time, language)
end
# Group not found with a path - try default group
defp handle_fallback_case(:group_not_found, [_group_slug | _], language) do
fallback_to_default_group(language)
end
defp handle_fallback_case(:group_not_found, [], language) do
fallback_to_default_group(language)
end
# Any post-level error with a 2+ segment path — fall back to group listing
# Catches errors like :invalid_version, unknown reasons from read_post, etc.
defp handle_fallback_case(_reason, [group_slug | _rest], language) do
if group_exists?(group_slug) do
{:ok, PublishingHTML.group_listing_path(language, group_slug)}
else
fallback_to_default_group(language)
end
end
defp handle_fallback_case(_reason, _path, _language), do: :no_fallback
# ============================================================================
# Slug Mode Fallback
# ============================================================================
defp fallback_to_default_language(group_slug, post_slug, requested_language) do
if group_exists?(group_slug) do
find_any_available_language_version(group_slug, post_slug, requested_language)
else
fallback_to_default_group(requested_language)
end
end
@doc """
Tries to find any available published language version of the post.
Priority:
1. Check for published versions in the SAME language first (across all versions)
2. Then try other languages
3. Falls back to group listing if no published versions exist
Note: fetch_post now handles finding the latest published version automatically,
so we can just use base URLs here (no version-specific URLs needed)
"""
def find_any_available_language_version(group_slug, post_slug, requested_language) do
default_lang = Language.get_default_language()
# Find the post in the group to get available languages
case find_post_by_slug(group_slug, post_slug) do
{:ok, post} ->
# The initial fetch failed, so we know no published version exists for the requested_language.
# Proceed directly to trying other available languages.
try_other_languages(group_slug, post_slug, post, requested_language, default_lang)
:not_found ->
# Post doesn't exist at all - fall back to group listing
{:ok, PublishingHTML.group_listing_path(default_lang, group_slug)}
end
end
# Finds the latest published version for a specific language
defp find_published_version_for_language(group_slug, post_slug, language) do
versions = Publishing.list_versions(group_slug, post_slug)
published_version =
versions
|> Enum.sort(:desc)
|> Enum.find(fn version ->
Publishing.get_version_status(group_slug, post_slug, version, language) == "published"
end)
case published_version do
nil -> :not_found
version -> {:ok, version}
end
end
# Tries other languages when requested language has no published versions
# Cap on how many languages to try in fallback chain to prevent excessive DB queries
@max_fallback_languages 5
defp try_other_languages(group_slug, post_slug, post, requested_language, default_lang) do
available = post.available_languages
# Build priority list: default first, then others (excluding already-tried language)
languages_to_try =
([default_lang | available] -- [requested_language])
|> Enum.uniq()
|> Enum.take(@max_fallback_languages)
find_first_published_version(group_slug, post_slug, post, languages_to_try, default_lang)
end
# Finds a post by its slug using a direct DB query
defp find_post_by_slug(group_slug, post_slug) do
case Publishing.read_post(group_slug, post_slug) do
{:ok, post} -> {:ok, post}
{:error, _} -> :not_found
end
end
# Tries each language in order until finding a published version
# Uses find_published_version_for_language to check across all versions
# fetch_post will automatically find the right version when the URL is visited
defp find_first_published_version(group_slug, post_slug, post, languages, fallback_lang) do
result =
Enum.find_value(languages, fn lang ->
# Check if any published version exists for this language
case find_published_version_for_language(group_slug, post_slug, lang) do
{:ok, _version} ->
# Published version exists - use base URL
# fetch_post will find the right version
{:ok, PublishingHTML.build_post_url(group_slug, post, lang)}
:not_found ->
nil
end
end)
# If no published version found, fall back to group listing
result || {:ok, PublishingHTML.group_listing_path(fallback_lang, group_slug)}
end
# ============================================================================
# Timestamp Mode Fallback
# ============================================================================
@doc """
Fallback for timestamp mode posts - comprehensive fallback chain:
1. Try other languages for the exact date/time
2. If time doesn't exist, try other times on the same date
3. If date has no posts, fall back to group listing
"""
def fallback_timestamp_to_other_language(group_slug, date, time, requested_language) do
default_lang = Language.get_default_language()
if group_exists?(group_slug) do
# Step 1: Try other languages for this exact time
# Use DB to get available languages for this timestamp post
available = get_available_languages_for_timestamp(group_slug, date, time)
# Time exists with language content - try other languages
if available != [] do
languages_to_try =
([default_lang | available] -- [requested_language])
|> Enum.uniq()
case find_first_published_timestamp_version(group_slug, date, time, languages_to_try) do
{:ok, url} ->
{:ok, url}
:not_found ->
# No published version at this time - try other times on this date
fallback_to_other_time_on_date(group_slug, date, time, default_lang)
end
else
# Time doesn't exist - try other times on this date
fallback_to_other_time_on_date(group_slug, date, time, default_lang)
end
else
fallback_to_default_group(requested_language)
end
end
# Fallback to another time on the same date
defp fallback_to_other_time_on_date(group_slug, date, exclude_time, default_lang) do
case Publishing.list_times_on_date(group_slug, date) do
[] ->
# No posts on this date at all - try other dates or fall back to group listing
fallback_to_other_date(group_slug, default_lang)
times ->
# Filter out the time we already tried
other_times = times -- [exclude_time]
case find_first_published_time(group_slug, date, other_times, default_lang) do
{:ok, url} ->
{:ok, url}
:not_found ->
# No published posts on this date - try other dates
fallback_to_other_date(group_slug, default_lang)
end
end
end
# No posts found on this date - fall back to group listing
# The group listing will show all available posts
defp fallback_to_other_date(group_slug, default_lang) do
{:ok, PublishingHTML.group_listing_path(default_lang, group_slug)}
end
# Find the first published post at any of the given times
defp find_first_published_time(group_slug, date, times, preferred_lang) do
Enum.find_value(times, fn time ->
available = get_available_languages_for_timestamp(group_slug, date, time)
if available != [] do
# Try preferred language first, then others
languages = [preferred_lang | available] |> Enum.uniq()
case find_first_published_timestamp_version(group_slug, date, time, languages) do
{:ok, url} -> {:ok, url}
:not_found -> nil
end
else
nil
end
end) || :not_found
end
@doc """
Tries each language for timestamp mode until finding a published version.
"""
def find_first_published_timestamp_version(group_slug, date, time, languages) do
identifier = "#{date}/#{time}"
Enum.find_value(languages, fn lang ->
case Publishing.read_post(group_slug, identifier, lang) do
{:ok, post} when post.metadata.status == "published" ->
{:ok, build_timestamp_url(group_slug, date, time, lang)}
_ ->
nil
end
end) || :not_found
end
# ============================================================================
# Group Fallback
# ============================================================================
defp fallback_to_default_group(language) do
case Listing.default_group_listing(language) do
nil -> :no_fallback
path -> {:ok, path}
end
end
# ============================================================================
# Helper Functions
# ============================================================================
defp group_exists?(group_slug) do
case Listing.fetch_group(group_slug) do
{:ok, _} -> true
_ -> false
end
end
defp build_timestamp_url(group_slug, date, time, language) do
PublishingHTML.build_public_path_with_time(language, group_slug, date, time)
end
# Gets available languages for a timestamp post using a direct DB query
defp get_available_languages_for_timestamp(group_slug, date, time) do
parsed_date = parse_date(date)
parsed_time = parse_time(time)
if parsed_date && parsed_time do
case Publishing.read_post_by_datetime(group_slug, parsed_date, parsed_time) do
{:ok, post} -> post.available_languages
{:error, _} -> []
end
else
[]
end
end
defp parse_date(%Date{} = d), do: d
defp parse_date(d) when is_binary(d) do
case Date.from_iso8601(d) do
{:ok, date} -> date
_ -> nil
end
end
defp parse_date(_), do: nil
defp parse_time(%Time{} = t), do: t
defp parse_time(t) when is_binary(t) do
case String.split(t, ":") do
[h, m | _] ->
with {hour, ""} <- Integer.parse(h),
{minute, ""} <- Integer.parse(m) do
case Time.new(hour, minute, 0) do
{:ok, time} -> time
_ -> nil
end
else
_ -> nil
end
_ ->
nil
end
end
defp parse_time(_), do: nil
end