Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions lib/modules/seo/seo.ex
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions lib/phoenix_kit/settings/settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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" => "",
Expand Down
83 changes: 25 additions & 58 deletions lib/phoenix_kit/users/rate_limiter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 """
Expand Down Expand Up @@ -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

Expand Down
72 changes: 69 additions & 3 deletions lib/phoenix_kit/utils/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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 """
Expand All @@ -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 """
Expand All @@ -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 """
Expand Down
36 changes: 25 additions & 11 deletions lib/phoenix_kit_web/components/admin_nav.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,30 @@ 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)

~H"""
<.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")
Expand Down Expand Up @@ -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" -> %>
Expand Down Expand Up @@ -480,19 +488,25 @@ 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)

# For nested items, use only exact and tab matching to prevent parent highlighting
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

Expand Down
Loading
Loading