diff --git a/lib/modules/seo/seo.ex b/lib/modules/seo/seo.ex new file mode 100644 index 000000000..7abe77df5 --- /dev/null +++ b/lib/modules/seo/seo.ex @@ -0,0 +1,81 @@ +defmodule PhoenixKit.Modules.SEO do + @moduledoc """ + SEO module for PhoenixKit. + + Provides project-wide search visibility controls. Currently supports a + `noindex, nofollow` directive for staging environments, and will be extended + with additional SEO options in the future. + """ + alias PhoenixKit.Settings + + @module_enabled_key "seo_module_enabled" + @no_index_key "seo_no_index" + @module_name "seo" + + @doc """ + Indicates whether the SEO module is available in the admin. + """ + def module_enabled? do + Settings.get_boolean_setting(@module_enabled_key, false) + end + + @doc """ + Enables the SEO module (exposes the settings page). + """ + def enable_module do + Settings.update_boolean_setting_with_module(@module_enabled_key, true, @module_name) + end + + @doc """ + Disables the SEO module and clears any active directives. + """ + def disable_module do + case Settings.update_boolean_setting_with_module(@module_enabled_key, false, @module_name) do + {:ok, _setting} = result -> + # Ensure site becomes indexable once the module is disabled + _ = update_no_index(false) + result + + {:error, _changeset} = error -> + error + end + end + + @doc """ + Returns true when the `noindex, nofollow` directive is active. + """ + def no_index_enabled? do + Settings.get_boolean_setting(@no_index_key, false) + end + + @doc """ + Enables the global `noindex, nofollow` directive. + """ + def enable_no_index do + update_no_index(true) + end + + @doc """ + Disables the global `noindex, nofollow` directive. + """ + def disable_no_index do + update_no_index(false) + end + + @doc """ + Updates the directive to the provided boolean value. + """ + def update_no_index(enabled?) when is_boolean(enabled?) do + Settings.update_boolean_setting_with_module(@no_index_key, enabled?, @module_name) + end + + @doc """ + Returns configuration metadata for dashboard cards and settings pages. + """ + def get_config do + %{ + module_enabled: module_enabled?(), + no_index_enabled: no_index_enabled?() + } + end +end diff --git a/lib/phoenix_kit/settings/settings.ex b/lib/phoenix_kit/settings/settings.ex index 4e52c9b36..dc6584801 100644 --- a/lib/phoenix_kit/settings/settings.ex +++ b/lib/phoenix_kit/settings/settings.ex @@ -138,6 +138,9 @@ defmodule PhoenixKit.Settings do "sqs_polling_interval_ms" => "5000", "sqs_max_messages_per_poll" => "10", "sqs_visibility_timeout" => "300", + # SEO + "seo_module_enabled" => "false", + "seo_no_index" => "false", # OAuth Provider Credentials "oauth_google_client_id" => "", "oauth_google_client_secret" => "", diff --git a/lib/phoenix_kit/users/rate_limiter.ex b/lib/phoenix_kit/users/rate_limiter.ex index 3c3bc25c2..b8bc62d99 100644 --- a/lib/phoenix_kit/users/rate_limiter.ex +++ b/lib/phoenix_kit/users/rate_limiter.ex @@ -64,6 +64,8 @@ defmodule PhoenixKit.Users.RateLimiter do require Logger + alias PhoenixKit.Users.RateLimiter.Backend + @default_config [ # Login: 5 attempts per minute per email login_limit: 5, @@ -260,58 +262,32 @@ defmodule PhoenixKit.Users.RateLimiter do @doc """ Resets rate limit for a specific action and identifier. - This is useful for: - - Admin intervention (clearing rate limits for legitimate users) - - Testing purposes - - Post-successful authentication cleanup + **DEPRECATED:** Hammer 7.x removed `delete_buckets` with no replacement. + This function now returns an error as Backend.set/3 requires positive integers (cannot set to 0). + + Rate limits will naturally expire after their configured window period. - For login and registration, the identifier should already include the prefix (e.g., "email:user@example.com" or "ip:192.168.1.1"). - For magic_link and password_reset, use just the email. + ## Migration - Note: With Hammer 7.x, this resets the counter to 0 using the set/3 function. + - **For testing**: Use `Application.put_env` to disable rate limiting + - **For admin intervention**: Wait for the time window to expire + - **For immediate reset**: Restart the application (clears ETS tables) + + See: https://hexdocs.pm/hammer/upgrade-v7.html ## Examples iex> PhoenixKit.Users.RateLimiter.reset_rate_limit(:login, "email:user@example.com") - :ok - - iex> PhoenixKit.Users.RateLimiter.reset_rate_limit(:magic_link, "user@example.com") - :ok + {:error, :not_supported} """ - def reset_rate_limit(action, identifier) when is_atom(action) and is_binary(identifier) do - # Normalize email if identifier doesn't already have a prefix (email: or ip:) - identifier = - if action in [:magic_link, :password_reset] and not String.contains?(identifier, ":") do - normalize_email(identifier) - else - identifier - end - - config = get_config() - key = "auth:#{action}:#{identifier}" - - # Get the window for this action type - window = - case action do - :login -> Keyword.get(config, :login_window_ms) - :magic_link -> Keyword.get(config, :magic_link_window_ms) - :password_reset -> Keyword.get(config, :password_reset_window_ms) - :registration -> Keyword.get(config, :registration_window_ms) - end - - # Hammer 7.x: Use set/3 to reset the counter to 0 - case PhoenixKit.Users.RateLimiter.Backend.set(key, window, 0) do - :ok -> - Logger.info("PhoenixKit.RateLimiter: Reset rate limit for #{action}:#{identifier}") - :ok - - {:error, reason} -> - Logger.error( - "PhoenixKit.RateLimiter: Failed to reset rate limit for #{action}:#{identifier}: #{inspect(reason)}" - ) + @deprecated "Hammer 7.x removed delete_buckets. Rate limits expire after their time window." + def reset_rate_limit(_action, _identifier) do + Logger.warning( + "PhoenixKit.RateLimiter.reset_rate_limit/2 is deprecated. " <> + "Rate limits expire automatically after their configured time window." + ) - {:error, reason} - end + {:error, :not_supported} end @doc """ @@ -361,30 +337,21 @@ defmodule PhoenixKit.Users.RateLimiter do end # Hammer 7.x: Use get/2 to retrieve the current count - case PhoenixKit.Users.RateLimiter.Backend.get(key, window) do - {:ok, count} when is_integer(count) -> - max(0, limit - count) - - _ -> - # If bucket doesn't exist or error, return full limit - limit - end + # Backend.get/2 returns an integer directly (current count) + count = Backend.get(key, window) + max(0, limit - count) end # Private functions defp check_rate_limit(key, window_ms, limit) do - case PhoenixKit.Users.RateLimiter.Backend.hit(key, window_ms, limit) do + # Hammer 7.x: Backend.hit/3 returns {:allow, count} or {:deny, retry_after} + case Backend.hit(key, window_ms, limit) do {:allow, _count} -> :ok {:deny, _retry_after_ms} -> {:error, :rate_limit_exceeded} - - {:error, reason} -> - # Log error but allow request to proceed (fail open for availability) - Logger.error("PhoenixKit.RateLimiter: Hammer error for #{key}: #{inspect(reason)}") - :ok end end diff --git a/lib/phoenix_kit/utils/date.ex b/lib/phoenix_kit/utils/date.ex index db390682a..b3cf65438 100644 --- a/lib/phoenix_kit/utils/date.ex +++ b/lib/phoenix_kit/utils/date.ex @@ -477,6 +477,12 @@ defmodule PhoenixKit.Utils.Date do shift_to_timezone_offset(datetime, user_timezone_offset) end + # Private helper to shift datetime to user's timezone using cached settings + defp shift_to_user_timezone_cached(datetime, user, settings) do + user_timezone_offset = get_user_timezone_cached(user, settings) + shift_to_timezone_offset(datetime, user_timezone_offset) + end + # Private helper to apply timezone offset to datetime defp shift_to_timezone_offset(datetime, timezone_offset) do case Integer.parse(timezone_offset) do @@ -491,6 +497,66 @@ defmodule PhoenixKit.Utils.Date do end end + # Cached variant of format_datetime_with_timezone + defp format_datetime_with_timezone_cached(datetime, format, user, settings) do + case datetime do + nil -> + "Never" + + %NaiveDateTime{} = naive_dt -> + utc_datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") + shifted_datetime = shift_to_user_timezone_cached(utc_datetime, user, settings) + format_datetime(shifted_datetime, format) + + %DateTime{} = dt -> + shifted_datetime = shift_to_user_timezone_cached(dt, user, settings) + format_datetime(shifted_datetime, format) + + _ -> + format_datetime(datetime, format) + end + end + + # Cached variant of format_date_with_timezone + defp format_date_with_timezone_cached(date, format, user, settings) do + case date do + %Date{} = d -> + format_date(d, format) + + %NaiveDateTime{} = naive_dt -> + utc_datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") + shifted_datetime = shift_to_user_timezone_cached(utc_datetime, user, settings) + format_date(DateTime.to_date(shifted_datetime), format) + + %DateTime{} = dt -> + shifted_datetime = shift_to_user_timezone_cached(dt, user, settings) + format_date(DateTime.to_date(shifted_datetime), format) + + _ -> + format_date(date, format) + end + end + + # Cached variant of format_time_with_timezone + defp format_time_with_timezone_cached(time, format, user, settings) do + case time do + %Time{} = t -> + format_time(t, format) + + %NaiveDateTime{} = naive_dt -> + utc_datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") + shifted_datetime = shift_to_user_timezone_cached(utc_datetime, user, settings) + format_time(DateTime.to_time(shifted_datetime), format) + + %DateTime{} = dt -> + shifted_datetime = shift_to_user_timezone_cached(dt, user, settings) + format_time(DateTime.to_time(shifted_datetime), format) + + _ -> + format_time(time, format) + end + end + @doc """ Gets the effective timezone for a user. @@ -583,7 +649,7 @@ defmodule PhoenixKit.Utils.Date do """ def format_datetime_with_user_timezone_cached(datetime, user, settings) do date_format = Map.get(settings, "date_format", "Y-m-d") - format_datetime_with_timezone(datetime, date_format, user) + format_datetime_with_timezone_cached(datetime, date_format, user, settings) end @doc """ @@ -598,7 +664,7 @@ defmodule PhoenixKit.Utils.Date do """ def format_date_with_user_timezone_cached(date, user, settings) do date_format = Map.get(settings, "date_format", "Y-m-d") - format_date_with_timezone(date, date_format, user) + format_date_with_timezone_cached(date, date_format, user, settings) end @doc """ @@ -613,7 +679,7 @@ defmodule PhoenixKit.Utils.Date do """ def format_time_with_user_timezone_cached(time, user, settings) do time_format = Map.get(settings, "time_format", "H:i") - format_time_with_timezone(time, time_format, user) + format_time_with_timezone_cached(time, time_format, user, settings) end @doc """ diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index a55a5c970..40b0b5955 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -43,12 +43,19 @@ defmodule PhoenixKitWeb.Components.AdminNav do attr(:mobile, :boolean, default: false) attr(:nested, :boolean, default: false) attr(:disable_active, :boolean, default: false) + attr(:exact_match_only, :boolean, default: false) def admin_nav_item(assigns) do active = if assigns.disable_active, do: false, - else: nav_item_active?(assigns.current_path, assigns.href, assigns.nested) + else: + nav_item_active?( + assigns.current_path, + assigns.href, + assigns.nested, + assigns.exact_match_only + ) assigns = assign(assigns, :active, active) @@ -56,11 +63,10 @@ defmodule PhoenixKitWeb.Components.AdminNav do <.link navigate={@href} class={[ - "flex items-center py-2 rounded-lg text-sm font-medium transition-colors", - "hover:bg-base-200 group", + "flex items-center py-2 rounded-lg text-sm font-medium transition-colors group", if(@active, - do: "bg-primary text-primary-content", - else: "text-base-content hover:text-primary" + do: "bg-primary text-primary-content hover:bg-primary/90", + else: "text-base-content hover:bg-base-200 hover:text-primary" ), if(@mobile, do: "w-full", else: ""), if(@nested, do: "pl-8 pr-3", else: "px-3") @@ -117,6 +123,8 @@ defmodule PhoenixKitWeb.Components.AdminNav do <.icon name="hero-cube" class="w-5 h-5" /> <% "language" -> %> <.icon name="hero-language" class="w-5 h-5" /> + <% "seo" -> %> + <.icon name="hero-magnifying-glass-circle" class="w-5 h-5" /> <% "document" -> %> <.icon name="hero-document-text" class="w-5 h-5" /> <% "maintenance" -> %> @@ -480,7 +488,7 @@ defmodule PhoenixKitWeb.Components.AdminNav do end # Helper function to determine if navigation item is active - defp nav_item_active?(current_path, href, nested) do + defp nav_item_active?(current_path, href, nested, exact_match_only) do current_parts = parse_admin_path(current_path) href_parts = parse_admin_path(href) @@ -488,11 +496,17 @@ defmodule PhoenixKitWeb.Components.AdminNav do if nested do exact_match?(current_parts, href_parts) or tab_match?(current_parts, href_parts) else - # For top-level items, use full hierarchical matching - exact_match?(current_parts, href_parts) or - tab_match?(current_parts, href_parts) or - parent_match?(current_parts, href_parts) or - hierarchical_match?(current_parts, href_parts) + # For top-level items with exact_match_only, skip hierarchical matching + base_matches = + exact_match?(current_parts, href_parts) or + tab_match?(current_parts, href_parts) or + parent_match?(current_parts, href_parts) + + if exact_match_only do + base_matches + else + base_matches or hierarchical_match?(current_parts, href_parts) + end end end diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 9e21a8c12..efae038b9 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -35,6 +35,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do alias Phoenix.HTML alias PhoenixKit.Config alias PhoenixKit.Module.Languages + alias PhoenixKit.Modules.SEO alias PhoenixKit.ThemeConfig alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.PhoenixVersion @@ -79,6 +80,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do PhoenixKit.Settings.get_content_language() end) |> assign_new(:blogging_blogs, fn -> load_blogging_blogs() end) + |> assign_new(:seo_no_index, fn -> SEO.no_index_enabled?() end) # Handle both inner_content (Phoenix 1.7-) and inner_block (Phoenix 1.8+) assigns = normalize_content_assigns(assigns) @@ -402,6 +404,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do icon="document" label="Blogging" current_path={@current_path || ""} + exact_match_only={true} /> <%= if submenu_open?(@current_path, ["/admin/blogging"]) do %> @@ -435,7 +438,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do disable_active={true} /> - <%= if submenu_open?(@current_path, ["/admin/settings", "/admin/settings/users", "/admin/settings/referral-codes", "/admin/settings/emails", "/admin/settings/languages", "/admin/settings/entities", "/admin/settings/storage", "/admin/settings/storage/dimensions", "/admin/settings/maintenance", "/admin/settings/blogging"]) do %> + <%= if submenu_open?(@current_path, ["/admin/settings", "/admin/settings/users", "/admin/settings/referral-codes", "/admin/settings/emails", "/admin/settings/languages", "/admin/settings/entities", "/admin/settings/storage", "/admin/settings/storage/dimensions", "/admin/settings/maintenance", "/admin/settings/blogging", "/admin/settings/seo"]) do %> <%!-- Settings submenu items --%>
+ Toggle the SEO module to unlock dedicated settings for search engines. +
+ + + <:status_badges> + + {if @seo_module_enabled, do: "Module Enabled", else: "Module Disabled"} + + <%= if @seo_module_enabled do %> + + {if @seo_no_index_enabled, do: "Noindex Active", else: "Indexing Allowed"} + + <% end %> + + + <:action_buttons> + <.link + navigate={ + PhoenixKit.Utils.Routes.path("/admin/settings/seo", locale: @current_locale || "en") + } + class={"btn btn-sm w-full #{if @seo_module_enabled, do: "btn-primary", else: "btn-outline"}"} + > + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure SEO + + +