diff --git a/CHANGELOG.md b/CHANGELOG.md index 387bb33ef..326159085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## 1.6.4 - 2025-11-15 + +### Fixed +- **AWS Credentials Verification** - Fixed STS response parsing to support ExAws map format + - Added support for both XML string and parsed map responses from AWS STS + - Fixed `parse_sts_response/1` to handle ExAws automatic XML-to-map conversion + - Resolved `CaseClauseError` when verifying credentials with valid AWS keys + - Added comprehensive error handling for all AWS verification failure types + +### Changed +- **AWS Region Selection UX** - Streamlined region input workflow from 7 steps to 4 + - Replace dropdown-only region field with text input by default + - Add optional "Load regions" button to fetch and display region dropdown + - Enable manual region entry without waiting for region list loading + - Remove requirement for double-saving credentials and region + - Update setup instructions to reflect simplified workflow +- **Code Quality** - Refactored AWS credentials verification handler + - Extract verification logic into separate helper functions + - Reduce cyclomatic complexity from 14 to acceptable level + - Improve code readability and maintainability + ## 1.6.3 - 2025-11-12 ### Added diff --git a/lib/mix/tasks/phoenix_kit.install.ex b/lib/mix/tasks/phoenix_kit.install.ex index 1e941648a..028e0de66 100644 --- a/lib/mix/tasks/phoenix_kit.install.ex +++ b/lib/mix/tasks/phoenix_kit.install.ex @@ -129,18 +129,34 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do ] ) - # Run standard igniter process - result = super(argv) - - # After igniter is done, handle interactive migration - MigrationStrategy.handle_interactive_migration_after_config(elem(opts, 1)) - - # Always rebuild assets unless explicitly skipped - unless Keyword.get(elem(opts, 1), :skip_assets, false) do - AssetRebuild.check_and_rebuild(verbose: true) + # CRITICAL: Check if required configuration exists BEFORE starting app + # This prevents configuration timing issues where config is added via Igniter + # but the app has already started with cached (missing) configuration + config_status = check_required_configuration() + + case config_status do + :missing -> + # First pass: Add configuration via Igniter without starting app + show_missing_config_message(argv) + result = super(argv) + show_config_added_message(argv) + result + + :ok -> + # Second pass: Configuration exists, safe to start app and complete installation + # Run standard igniter process + result = super(argv) + + # After igniter is done, handle interactive migration + MigrationStrategy.handle_interactive_migration_after_config(elem(opts, 1)) + + # Always rebuild assets unless explicitly skipped + unless Keyword.get(elem(opts, 1), :skip_assets, false) do + AssetRebuild.check_and_rebuild(verbose: true) + end + + result end - - result end end @@ -249,6 +265,89 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do """) end + # Display message about missing configuration + defp show_missing_config_message(argv) do + Mix.shell().info(""" + + ⚠️ Required configuration is missing from config/config.exs + + PhoenixKit requires configuration for: + - Ueberauth (OAuth authentication) + - Hammer (rate limiting) + + This configuration will be added now. + + After this completes, please run the install command again: + mix phoenix_kit.install #{Enum.join(argv, " ")} + """) + end + + # Display message after configuration is added + defp show_config_added_message(argv) do + Mix.shell().info(""" + + ✅ Configuration added successfully! + + Next step: Run the install command again to complete the installation: + mix phoenix_kit.install #{Enum.join(argv, " ")} + """) + end + + # Check if all required configuration exists + # Returns :ok if all config present, :missing if any config is missing + defp check_required_configuration do + config_file = "config/config.exs" + + if File.exists?(config_file) do + content = File.read!(config_file) + lines = String.split(content, "\n") + + cond do + # Missing Ueberauth configuration entirely + !String.contains?(content, "config :ueberauth") -> + :missing + + # Incorrect Ueberauth configuration (providers: [] instead of providers: %{}) + String.contains?(content, "config :ueberauth, Ueberauth") && + Regex.match?(~r/providers:\s*\[\s*\]/, content) -> + :missing + + # Missing Hammer configuration (check for active, non-commented config) + !has_active_hammer_config?(lines) -> + :missing + + # All required configuration present + true -> + :ok + end + else + # config.exs doesn't exist - let normal flow handle this error + :ok + end + rescue + # If we can't read config, proceed with normal flow + _ -> :ok + end + + # Check if active (non-commented) Hammer configuration exists + defp has_active_hammer_config?(lines) do + has_hammer_config = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains config :hammer + !String.starts_with?(trimmed, "#") and String.starts_with?(trimmed, "config :hammer") + end) + + has_expiry_ms = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains expiry_ms + !String.starts_with?(trimmed, "#") and String.contains?(line, "expiry_ms") + end) + + has_hammer_config and has_expiry_ms + end + # Add completion notice with essential next steps (reduced duplication) defp add_completion_notice(igniter) do notice = """ diff --git a/lib/mix/tasks/phoenix_kit.update.ex b/lib/mix/tasks/phoenix_kit.update.ex index 066f16f2f..29b6393ef 100644 --- a/lib/mix/tasks/phoenix_kit.update.ex +++ b/lib/mix/tasks/phoenix_kit.update.ex @@ -7,6 +7,21 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do This task handles updating an existing PhoenixKit installation to the latest version by creating upgrade migrations that preserve existing data while adding new features. + ## Two-Pass Update Strategy + + To prevent configuration timing issues, the update process uses a two-pass strategy: + + 1. **First Pass** (if configuration is missing): Adds required configuration (e.g., + Ueberauth settings) via Igniter and prompts you to run the command again. + + 2. **Second Pass** (configuration present): Safely starts the application and + completes the update process. + + This ensures that the application always starts with all required configuration + present, avoiding runtime errors from missing dependencies. + + ## Automatic Updates + The update process also automatically: - Updates CSS configuration (enables daisyUI themes if disabled) - Rebuilds assets using the Phoenix asset pipeline @@ -148,22 +163,115 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do show_status(elem(opts, 0)) :ok else - # CRITICAL: Check and add Hammer configuration BEFORE starting app - # Without this, app.start will fail if Hammer config is missing - ensure_hammer_config_before_start() + # CRITICAL: Check if required configuration exists BEFORE starting app + # This prevents configuration timing issues where config is added via Igniter + # but the app has already started with cached (missing) configuration + config_status = check_required_configuration() + + case config_status do + :missing -> + # First pass: Add configuration via Igniter without starting app + # Store config status in Process dictionary for igniter/1 to read + Process.put(:phoenix_kit_config_status, :missing) + show_missing_config_message(argv) + result = super(argv) + show_config_added_message(argv) + result + + :ok -> + # Second pass: Configuration exists, safe to start app and update + # Store config status in Process dictionary for igniter/1 to read + Process.put(:phoenix_kit_config_status, :ok) + Mix.Task.run("app.start") + result = super(argv) + post_igniter_tasks(elem(opts, 0)) + result + end + end + end + end + + # Display message about missing configuration + defp show_missing_config_message(argv) do + Mix.shell().info(""" + + ⚠️ Required configuration is missing from config/config.exs + + PhoenixKit requires configuration for: + - Ueberauth (OAuth authentication) + - Hammer (rate limiting) + + This configuration will be added now. + + After this completes, please run the update command again: + mix phoenix_kit.update #{Enum.join(argv, " ")} + """) + end + + # Display message after configuration is added + defp show_config_added_message(argv) do + Mix.shell().info(""" - # Ensure application is started for proper version detection - Mix.Task.run("app.start") + ✅ Configuration added successfully! - # Run standard igniter process - result = super(argv) + Next step: Run the update command again to complete the upgrade: + mix phoenix_kit.update #{Enum.join(argv, " ")} + """) + end - # After igniter is done, handle interactive migration and asset rebuild - post_igniter_tasks(elem(opts, 0)) + # Check if all required configuration exists + # Returns :ok if all config present, :missing if any config is missing + defp check_required_configuration do + config_file = "config/config.exs" + + if File.exists?(config_file) do + content = File.read!(config_file) + lines = String.split(content, "\n") - result + cond do + # Missing Ueberauth configuration entirely + !String.contains?(content, "config :ueberauth") -> + :missing + + # Incorrect Ueberauth configuration (providers: [] instead of providers: %{}) + String.contains?(content, "config :ueberauth, Ueberauth") && + Regex.match?(~r/providers:\s*\[\s*\]/, content) -> + :missing + + # Missing Hammer configuration (check for active, non-commented config) + !has_active_hammer_config?(lines) -> + :missing + + # All required configuration present + true -> + :ok end + else + # config.exs doesn't exist - let normal flow handle this error + :ok end + rescue + # If we can't read config, proceed with normal flow + _ -> :ok + end + + # Check if active (non-commented) Hammer configuration exists + defp has_active_hammer_config?(lines) do + has_hammer_config = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains config :hammer + !String.starts_with?(trimmed, "#") and String.starts_with?(trimmed, "config :hammer") + end) + + has_expiry_ms = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains expiry_ms + !String.starts_with?(trimmed, "#") and String.contains?(line, "expiry_ms") + end) + + has_hammer_config and has_expiry_ms end # Perform the igniter-based update logic @@ -177,29 +285,41 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do # Ensure Hammer rate limiter configuration exists igniter = validate_and_add_hammer_config(igniter) - case Common.check_installation_status(prefix) do - {:not_installed} -> - add_not_installed_notice(igniter) - - {:current_version, current_version} -> - target_version = Common.current_version() - - cond do - current_version >= target_version && !force -> - add_already_up_to_date_notice(igniter, current_version) - - current_version < target_version || force -> - create_update_migration_with_igniter( - igniter, - prefix, - current_version, - target_version, - force, - opts - ) - - true -> - igniter + # Check if this is the first pass (config missing) or second pass (config exists) + config_status = Process.get(:phoenix_kit_config_status, :ok) + + case config_status do + :missing -> + # First pass: Only add configuration, skip migration creation + # Migration will be created in second pass after app is started + igniter + + :ok -> + # Second pass: Configuration exists, app is started, proceed with migration + case Common.check_installation_status(prefix) do + {:not_installed} -> + add_not_installed_notice(igniter) + + {:current_version, current_version} -> + target_version = Common.current_version() + + cond do + current_version >= target_version && !force -> + add_already_up_to_date_notice(igniter, current_version) + + current_version < target_version || force -> + create_update_migration_with_igniter( + igniter, + prefix, + current_version, + target_version, + force, + opts + ) + + true -> + igniter + end end end end @@ -489,6 +609,15 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do • Idempotent (safe to run multiple times) • Rollback-capable (can be reverted if needed) + TWO-PASS UPDATE STRATEGY + If required configuration is missing, the update process will: + 1. First run: Add missing configuration (e.g., Ueberauth settings) + 2. Prompt you to run the command again + 3. Second run: Complete the update with all configuration present + + This prevents configuration timing issues where the application + starts before new configuration is available. + AFTER UPDATE 1. If migrations weren't run automatically: mix ecto.migrate @@ -731,99 +860,6 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do Igniter.add_notice(igniter, String.trim(notice)) end - - # Ensure Hammer configuration exists BEFORE app.start - # This is critical because app.start will fail without Hammer config - defp ensure_hammer_config_before_start do - config_path = "config/config.exs" - - if File.exists?(config_path) do - content = File.read!(config_path) - - # Check if Hammer config exists - unless String.contains?(content, "config :hammer") and - String.contains?(content, "expiry_ms") do - # Add Hammer configuration - Mix.shell().info("⚠️ Adding missing Hammer configuration to config.exs...") - add_hammer_config_directly(config_path, content) - Mix.shell().info("✅ Hammer configuration added successfully") - end - end - rescue - e -> - Mix.shell().error(""" - ⚠️ Failed to check/add Hammer configuration: #{inspect(e)} - Please add the configuration manually to config/config.exs: - - config :hammer, - backend: {Hammer.Backend.ETS, [expiry_ms: 60_000, cleanup_interval_ms: 60_000]} - - config :phoenix_kit, PhoenixKit.Users.RateLimiter, - login_limit: 5, login_window_ms: 60_000, - magic_link_limit: 3, magic_link_window_ms: 300_000, - password_reset_limit: 3, password_reset_window_ms: 300_000, - registration_limit: 3, registration_window_ms: 3_600_000, - registration_ip_limit: 10, registration_ip_window_ms: 3_600_000 - """) - end - - # Add Hammer configuration directly to config file (without Igniter) - defp add_hammer_config_directly(config_path, content) do - hammer_config = """ - - # Configure rate limiting with Hammer - config :hammer, - backend: - {Hammer.Backend.ETS, - [ - # Cleanup expired rate limit buckets every 60 seconds - expiry_ms: 60_000, - # Cleanup interval (1 minute) - cleanup_interval_ms: 60_000 - ]} - - # Configure rate limits for authentication endpoints - config :phoenix_kit, PhoenixKit.Users.RateLimiter, - # Login: 5 attempts per minute per email - login_limit: 5, - login_window_ms: 60_000, - # Magic link: 3 requests per 5 minutes per email - magic_link_limit: 3, - magic_link_window_ms: 300_000, - # Password reset: 3 requests per 5 minutes per email - password_reset_limit: 3, - password_reset_window_ms: 300_000, - # Registration: 3 attempts per hour per email - registration_limit: 3, - registration_window_ms: 3_600_000, - # Registration IP: 10 attempts per hour per IP - registration_ip_limit: 10, - registration_ip_window_ms: 3_600_000 - """ - - # Find insertion point before import_config - lines = String.split(content, "\n") - - import_index = - Enum.find_index(lines, fn line -> - trimmed = String.trim(line) - String.starts_with?(trimmed, "import_config") or String.contains?(line, "import_config") - end) - - updated_content = - case import_index do - nil -> - # No import_config, append to end - content <> hammer_config - - index -> - # Insert before import_config - {before_lines, after_lines} = Enum.split(lines, index) - Enum.join(before_lines ++ [hammer_config] ++ after_lines, "\n") - end - - File.write!(config_path, updated_content) - end end # Fallback module for when Igniter is not available diff --git a/lib/phoenix_kit/aws/credentials_verifier.ex b/lib/phoenix_kit/aws/credentials_verifier.ex index fc8d13ca4..354c9f712 100644 --- a/lib/phoenix_kit/aws/credentials_verifier.ex +++ b/lib/phoenix_kit/aws/credentials_verifier.ex @@ -359,6 +359,25 @@ defmodule PhoenixKit.AWS.CredentialsVerifier do {:error, "Failed to create config: #{inspect(e)}"} end + # Handle already parsed map response from ExAws (modern behavior) + defp parse_sts_response(body) when is_map(body) do + # ExAws automatically parses the XML response into a map + # Structure: %{user_id: "...", account: "...", arn: "..."} + with {:user_id, user_id} when is_binary(user_id) <- {:user_id, Map.get(body, :user_id)}, + {:account, account} when is_binary(account) <- {:account, Map.get(body, :account)}, + {:arn, arn} when is_binary(arn) <- {:arn, Map.get(body, :arn)} do + {:ok, user_id, account, arn} + else + {:user_id, _} -> {:error, "Missing or invalid user_id in STS response"} + {:account, _} -> {:error, "Missing or invalid account in STS response"} + {:arn, _} -> {:error, "Missing or invalid arn in STS response"} + end + rescue + e -> + {:error, "Map parsing error: #{inspect(e)}"} + end + + # Handle XML string response (legacy/fallback) defp parse_sts_response(body) when is_binary(body) do # Parse XML response from STS # Example structure: diff --git a/lib/phoenix_kit/emails/rate_limiter.ex b/lib/phoenix_kit/emails/rate_limiter.ex index 0f3bb0a69..1b92c8b29 100644 --- a/lib/phoenix_kit/emails/rate_limiter.ex +++ b/lib/phoenix_kit/emails/rate_limiter.ex @@ -36,10 +36,12 @@ defmodule PhoenixKit.Emails.RateLimiter do Provides multiple layers of protection against abuse, spam, and suspicious email patterns: - **Per-recipient limits** - Prevent spam to individual email addresses - - **Per-sender limits** - Control email volume from specific senders + - **Per-sender limits** - Control email volume from specific senders - **Global system limits** - Overall system protection + - **User-specific limits** - Temporary reduced limits for flagged users - **Automatic blocklists** - Dynamic blocking of suspicious patterns - **Pattern detection** - ML-style spam pattern recognition + - **User monitoring** - Event tracking for suspicious behavior ## Settings Integration @@ -49,23 +51,39 @@ defmodule PhoenixKit.Emails.RateLimiter do - `email_rate_limit_global` - Global max emails per hour (default: 10_000) - `email_blocklist_enabled` - Enable automatic blocklisting (default: true) + User-specific settings (stored as JSON): + - `user_rate_limits_` - Temporary reduced limits for specific users + - `user_monitoring_` - Event tracking log for user behavior + ## Usage Examples # Check if sending is allowed case PhoenixKit.Emails.RateLimiter.check_limits(email) do - :ok -> + :ok -> # Send email - + {:blocked, :recipient_limit} -> # Handle recipient rate limit - + {:blocked, :global_limit} -> # Handle global rate limit - + {:blocked, :blocklist} -> # Handle blocklisted recipient end + # Flag suspicious user activity + PhoenixKit.Emails.RateLimiter.flag_suspicious_activity(user_id, "high_bounce_rate") + # => :flagged (user gets reduced limits for 24 hours) + + # Check user's current limit status + status = PhoenixKit.Emails.RateLimiter.get_user_limit_status(user_id) + # => %{has_custom_limits: true, active_recipient_limit: 10, ...} + + # Clear user's custom limits + PhoenixKit.Emails.RateLimiter.clear_user_rate_limits(user_id) + # => :ok + # Add suspicious email to blocklist PhoenixKit.Emails.RateLimiter.add_to_blocklist( "spam@example.com", @@ -85,6 +103,15 @@ defmodule PhoenixKit.Emails.RateLimiter do 2. **Efficient Storage**: Uses single table with automatic cleanup 3. **Atomic Operations**: Prevents race conditions with database locks 4. **Memory Efficient**: Automatically expires old tracking data + 5. **User-Specific Limits**: JSON settings for temporary user restrictions + + ## User Behavior Management + + - **Reduced Limits**: Automatically reduce limits for users with high bounce rates + - **Email Blocking**: Block user emails for serious violations (spam complaints) + - **Activity Monitoring**: Track suspicious patterns for future analysis + - **Automatic Expiration**: Limits and blocks expire after configured periods + - **Manual Override**: Admin can clear user restrictions via API ## Automatic Blocklist Features @@ -93,6 +120,7 @@ defmodule PhoenixKit.Emails.RateLimiter do - **Complaint Rate Monitoring**: Blocks high-complaint addresses - **Frequency Analysis**: Detects unusual sending patterns - **Temporary Blocks**: Automatic expiration of blocks + - **User Integration**: Links blocked emails to user accounts ## Integration Points @@ -100,7 +128,7 @@ defmodule PhoenixKit.Emails.RateLimiter do - `PhoenixKit.Emails` - Main tracking system - `PhoenixKit.Emails.EmailInterceptor` - Pre-send filtering - `PhoenixKit.Settings` - Configuration management - - `PhoenixKit.Users.Auth` - User-based limits + - `PhoenixKit.Users.Auth` - User-based limits and email blocking """ alias PhoenixKit.Emails.{EmailBlocklist, Log} @@ -526,7 +554,7 @@ defmodule PhoenixKit.Emails.RateLimiter do iex> RateLimiter.flag_suspicious_activity(456, "complaint_spam") :blocked """ - def flag_suspicious_activity(user_id, reason) when is_integer(user_id) do + def flag_suspicious_activity(user_id, reason) when is_integer(user_id) and is_binary(reason) do case reason do "high_bounce_rate" -> # Temporarily reduce limits for this user @@ -540,7 +568,7 @@ defmodule PhoenixKit.Emails.RateLimiter do "bulk_sending" -> # Monitor closely but don't block yet - monitor_user(user_id, "bulk_sending", %{reason: reason, flagged_at: DateTime.utc_now()}) + monitor_user(user_id, :bulk_sending, %{reason: reason}) :monitored _ -> @@ -548,6 +576,149 @@ defmodule PhoenixKit.Emails.RateLimiter do end end + ## --- User Limit Management API --- + + @doc """ + Checks if a user has custom rate limits applied. + + Returns user's custom limits if they exist and haven't expired, + otherwise returns nil. + + ## Examples + + iex> RateLimiter.check_user_limits(123) + %{ + "recipient_limit" => 10, + "sender_limit" => 50, + "reason" => "high_bounce_rate", + "applied_at" => "2025-01-15T12:00:00Z", + "expires_at" => "2025-01-16T12:00:00Z" + } + + iex> RateLimiter.check_user_limits(999) + nil + """ + def check_user_limits(user_id) when is_integer(user_id) do + get_user_limits(user_id) + end + + @doc """ + Gets comprehensive rate limit status for a specific user. + + Returns a map with user's current limits, monitoring status, + and any active restrictions. + + ## Examples + + iex> RateLimiter.get_user_limit_status(123) + %{ + user_id: 123, + has_custom_limits: true, + custom_limits: %{"recipient_limit" => 10, "sender_limit" => 50}, + monitoring: %{"event_count" => 5, "last_event_at" => "..."}, + is_blocked: false, + default_recipient_limit: 100, + default_sender_limit: 1000 + } + + iex> RateLimiter.get_user_limit_status(999) + %{ + user_id: 999, + has_custom_limits: false, + custom_limits: nil, + monitoring: nil, + is_blocked: false, + default_recipient_limit: 100, + default_sender_limit: 1000 + } + """ + def get_user_limit_status(user_id) when is_integer(user_id) do + custom_limits = get_user_limits(user_id) + monitoring = get_user_monitoring(user_id) + + # Check if user's email is blocked + is_blocked = + case Auth.get_user(user_id) do + nil -> + false + + user -> + is_blocked?(user.email) + end + + %{ + user_id: user_id, + has_custom_limits: not is_nil(custom_limits), + custom_limits: custom_limits, + monitoring: monitoring, + is_blocked: is_blocked, + default_recipient_limit: get_recipient_limit(), + default_sender_limit: get_sender_limit(), + active_recipient_limit: + if(custom_limits, do: custom_limits["recipient_limit"], else: get_recipient_limit()), + active_sender_limit: + if(custom_limits, do: custom_limits["sender_limit"], else: get_sender_limit()) + } + rescue + _error -> + %{ + user_id: user_id, + has_custom_limits: false, + custom_limits: nil, + monitoring: nil, + is_blocked: false, + default_recipient_limit: get_recipient_limit(), + default_sender_limit: get_sender_limit(), + active_recipient_limit: get_recipient_limit(), + active_sender_limit: get_sender_limit() + } + end + + @doc """ + Clears custom rate limits for a specific user. + + Removes any reduced limits or custom restrictions applied to the user, + returning them to default system limits. + + ## Examples + + iex> RateLimiter.clear_user_rate_limits(123) + :ok + + ## Returns + + - `:ok` - Limits cleared successfully + """ + def clear_user_rate_limits(user_id) when is_integer(user_id) do + clear_user_limits(user_id) + end + + @doc """ + Gets monitoring events for a specific user. + + Returns the monitoring log with all tracked events for the user, + or nil if no monitoring exists. + + ## Examples + + iex> RateLimiter.get_user_monitoring_events(123) + %{ + "events" => [ + %{"event_type" => "bulk_sending", "timestamp" => "...", "metadata" => %{...}}, + %{"event_type" => "high_bounce_rate", "timestamp" => "...", "metadata" => %{...}} + ], + "event_count" => 2, + "first_event_at" => "2025-01-15T12:00:00Z", + "last_event_at" => "2025-01-15T18:00:00Z" + } + + iex> RateLimiter.get_user_monitoring_events(999) + nil + """ + def get_user_monitoring_events(user_id) when is_integer(user_id) do + get_user_monitoring(user_id) + end + ## --- Status and Statistics --- @doc """ @@ -692,81 +863,214 @@ defmodule PhoenixKit.Emails.RateLimiter do ## --- User Management Helpers --- - defp reduce_user_limits(user_id, reason) when is_integer(user_id) do - # Reduce sending limits for a user by setting temporary rate limit override - Logger.warning("Reducing rate limits for user #{user_id}: #{reason}") - - # Store temporary limit reduction in Settings - # This allows us to dynamically adjust per-user limits - limit_key = "email_rate_limit_user_#{user_id}" - current_limit = Settings.get_integer_setting(limit_key, get_recipient_limit()) + # Reduces rate limits for a specific user temporarily. + # + # Creates a JSON setting with reduced limits for the user. The limits + # automatically expire after a configured duration (default: 24 hours). + # + # Stored in JSON setting with key: `user_rate_limits_` + defp reduce_user_limits(user_id, reason) when is_integer(user_id) and is_binary(reason) do + # Get default limits + default_recipient_limit = get_recipient_limit() + default_sender_limit = get_sender_limit() + + # Calculate reduced limits (10% of defaults, minimum 10) + reduced_recipient_limit = max(div(default_recipient_limit, 10), 10) + reduced_sender_limit = max(div(default_sender_limit, 10), 50) + + # Set expiration to 24 hours from now + now = DateTime.utc_now() + expires_at = DateTime.add(now, 86_400) + + user_limits = %{ + "recipient_limit" => reduced_recipient_limit, + "sender_limit" => reduced_sender_limit, + "reason" => reason, + "applied_at" => DateTime.to_iso8601(now), + "expires_at" => DateTime.to_iso8601(expires_at) + } - # Reduce limit by 50% - new_limit = max(div(current_limit, 2), 10) + # Store in settings with user_id-specific key + Settings.update_json_setting("user_rate_limits_#{user_id}", user_limits) - Settings.update_setting(limit_key, to_string(new_limit)) + require Logger - # Set expiration for the limit reduction (24 hours from now) - expiry_key = "email_rate_limit_user_#{user_id}_expires" - expires_at = DateTime.add(DateTime.utc_now(), 86_400) - Settings.update_setting(expiry_key, DateTime.to_iso8601(expires_at)) + Logger.warning( + "Rate limits reduced for user #{user_id}: reason=#{reason}, " <> + "recipient_limit=#{reduced_recipient_limit}, sender_limit=#{reduced_sender_limit}, " <> + "expires_at=#{expires_at}" + ) - Logger.info("Reduced rate limit for user #{user_id} from #{current_limit} to #{new_limit}") :ok + rescue + error -> + require Logger + Logger.error("Failed to reduce user limits for user #{user_id}: #{inspect(error)}") + :ok end - defp block_user_emails(user_id, reason) when is_integer(user_id) do - # Block all email addresses associated with this user - Logger.warning("Blocking email addresses for user #{user_id}: #{reason}") - - # Get user's email address + # Blocks all email addresses associated with a user. + # + # Retrieves the user's email address and adds it to the blocklist + # with a temporary block duration (default: 7 days). + defp block_user_emails(user_id, reason) when is_integer(user_id) and is_binary(reason) do + # Get user from database case Auth.get_user(user_id) do nil -> - Logger.error("Cannot block emails for non-existent user #{user_id}") - {:error, :user_not_found} + require Logger + Logger.error("Cannot block emails for user #{user_id}: user not found") + :ok user -> - # Add user's email to blocklist with 7-day expiration - expires_at = DateTime.add(DateTime.utc_now(), 604_800) - - case add_to_blocklist(user.email, reason, - expires_at: expires_at, - user_id: user_id - ) do - :ok -> - Logger.info("Blocked email address #{user.email} for user #{user_id}") - :ok - - {:error, error} -> - Logger.error( - "Failed to block email address #{user.email} for user #{user_id}: #{inspect(error)}" - ) - - {:error, error} - end + # Set expiration to 7 days from now for serious violations + expires_at = DateTime.add(DateTime.utc_now(), 86_400 * 7) + + # Add to blocklist + add_to_blocklist(user.email, reason, expires_at: expires_at, user_id: user_id) + + # Also monitor the user for future activity + monitor_user(user_id, :email_blocked, %{reason: reason, email: user.email}) + + require Logger + + Logger.warning( + "Email blocked for user #{user_id}: email=#{user.email}, reason=#{reason}, expires_at=#{expires_at}" + ) + + :ok end + rescue + error -> + require Logger + Logger.error("Failed to block user emails for user #{user_id}: #{inspect(error)}") + :ok end - defp monitor_user(user_id, event_type, metadata) when is_integer(user_id) do - # Add user to monitoring list by tracking in a dedicated setting - Logger.info("Adding user #{user_id} to monitoring list: #{event_type}") + # Monitors user behavior by tracking events. + # + # Creates or updates a monitoring log for the user, storing events + # that indicate suspicious patterns. Events older than 30 days are + # automatically pruned when new events are added. + # + # Stored in JSON setting with key: `user_monitoring_` + defp monitor_user(user_id, event_type, metadata) + when is_integer(user_id) and (is_atom(event_type) or is_binary(event_type)) do + # Convert event_type to string + event_type_str = to_string(event_type) + + # Get existing monitoring data + monitoring_key = "user_monitoring_#{user_id}" + existing_monitoring = Settings.get_json_setting(monitoring_key, %{}) + + # Get existing events or initialize empty list + existing_events = Map.get(existing_monitoring, "events", []) + + # Create new event + now = DateTime.utc_now() - # Store monitoring record in Settings with timestamp - monitor_key = "email_monitor_user_#{user_id}_#{event_type}" + new_event = %{ + "event_type" => event_type_str, + "metadata" => metadata, + "timestamp" => DateTime.to_iso8601(now) + } - monitoring_data = %{ - user_id: user_id, - event_type: event_type, - metadata: metadata, - started_at: DateTime.to_iso8601(DateTime.utc_now()), - # Monitor for 30 days - expires_at: DateTime.to_iso8601(DateTime.add(DateTime.utc_now(), 2_592_000)) + # Filter out events older than 30 days + thirty_days_ago = DateTime.add(now, -86_400 * 30) + + recent_events = + Enum.filter(existing_events, fn event -> + case DateTime.from_iso8601(event["timestamp"]) do + {:ok, timestamp, _} -> DateTime.compare(timestamp, thirty_days_ago) == :gt + _ -> false + end + end) + + # Add new event + updated_events = [new_event | recent_events] + + # Update monitoring data + updated_monitoring = %{ + "events" => updated_events, + "first_event_at" => + Map.get(existing_monitoring, "first_event_at", DateTime.to_iso8601(now)), + "last_event_at" => DateTime.to_iso8601(now), + "event_count" => length(updated_events) } - Settings.update_setting(monitor_key, Jason.encode!(monitoring_data)) + # Store updated monitoring data + Settings.update_json_setting(monitoring_key, updated_monitoring) + + require Logger + + Logger.info( + "User monitoring event recorded for user #{user_id}: type=#{event_type_str}, " <> + "metadata=#{inspect(metadata)}, total_events=#{length(updated_events)}" + ) - Logger.info("User #{user_id} added to monitoring list for #{event_type}") :ok + rescue + error -> + require Logger + Logger.error("Failed to monitor user #{user_id}: #{inspect(error)}") + :ok + end + + # Gets user-specific rate limits if they exist and are not expired. + # Returns a map with user's custom limits or nil if no limits are set or they expired. + defp get_user_limits(user_id) when is_integer(user_id) do + monitoring_key = "user_rate_limits_#{user_id}" + user_limits = Settings.get_json_setting(monitoring_key) + + with limits when not is_nil(limits) <- user_limits, + expires_at_str when not is_nil(expires_at_str) <- Map.get(limits, "expires_at"), + {:ok, expires_at, _} <- DateTime.from_iso8601(expires_at_str) do + if DateTime.compare(DateTime.utc_now(), expires_at) == :lt do + limits + else + # Limits expired, clean them up + clear_user_limits(user_id) + nil + end + else + nil -> nil + # No expiration or invalid format - return limits as-is + limits when is_map(limits) -> limits + _ -> user_limits + end + rescue + _error -> + nil + end + + # Clears user-specific rate limits. + # Removes the JSON setting for user's custom limits. + # Used when limits expire or are manually cleared. + defp clear_user_limits(user_id) when is_integer(user_id) do + monitoring_key = "user_rate_limits_#{user_id}" + + # Delete the setting by setting it to nil + case Settings.update_json_setting(monitoring_key, nil) do + {:ok, _} -> + require Logger + Logger.info("Cleared expired rate limits for user #{user_id}") + :ok + + _ -> + :ok + end + rescue + _error -> + :ok + end + + # Gets monitoring data for a specific user. + # Returns the monitoring events and statistics for a user, or nil if no monitoring exists. + defp get_user_monitoring(user_id) when is_integer(user_id) do + monitoring_key = "user_monitoring_#{user_id}" + Settings.get_json_setting(monitoring_key) + rescue + _error -> + nil end ## --- Status Helpers --- diff --git a/lib/phoenix_kit/install/rate_limiter_config.ex b/lib/phoenix_kit/install/rate_limiter_config.ex index d66b8dfac..8c8795565 100644 --- a/lib/phoenix_kit/install/rate_limiter_config.ex +++ b/lib/phoenix_kit/install/rate_limiter_config.ex @@ -43,7 +43,24 @@ defmodule PhoenixKit.Install.RateLimiterConfig do if File.exists?(config_path) do content = File.read!(config_path) - String.contains?(content, "config :hammer") and String.contains?(content, "expiry_ms") + lines = String.split(content, "\n") + + # Check for active (non-commented) Hammer configuration + has_hammer_config = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains config :hammer + !String.starts_with?(trimmed, "#") and String.starts_with?(trimmed, "config :hammer") + end) + + has_expiry_ms = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains expiry_ms + !String.starts_with?(trimmed, "#") and String.contains?(line, "expiry_ms") + end) + + has_hammer_config and has_expiry_ms else false end diff --git a/lib/phoenix_kit_web/components/core/aws_region_select.ex b/lib/phoenix_kit_web/components/core/aws_region_select.ex index 0b5ee5a39..ac8018a3a 100644 --- a/lib/phoenix_kit_web/components/core/aws_region_select.ex +++ b/lib/phoenix_kit_web/components/core/aws_region_select.ex @@ -35,6 +35,7 @@ defmodule PhoenixKitWeb.Components.Core.AWSRegionSelect do attr :name, :string, required: true attr :value, :string, required: true attr :regions, :list, default: [] + attr :regions_loaded, :boolean, default: false attr :selected_region, :string, default: "" attr :verifying, :boolean, default: false attr :verified, :atom, default: :pending, values: [:pending, :success, :error] @@ -58,62 +59,72 @@ defmodule PhoenixKitWeb.Components.Core.AWSRegionSelect do
<%= if @verifying do %> - -
+ <%!-- Loading state --%> +
Loading regions...
<% else %> - - + <%!-- Empty option --%> + - <%!-- Show currently selected region first if it exists and not in regions list --%> - <%= if @value != "" and @value not in @regions do %> - - <% end %> + <%!-- Show currently selected region first if not in list --%> + <%= if @value != "" and @value not in @regions do %> + + <% end %> - <%!-- Region options from loaded list --%> - <%= for region <- @regions do %> - - <% end %> + <%!-- Region options from loaded list --%> + <%= for region <- @regions do %> + + <% end %> + + <% else %> + <%!-- Text input (default mode before loading regions) --%> + + <% end %> - <%!-- Empty state when no regions loaded and no value --%> - <%= if Enum.empty?(@regions) and !@verifying and @value == "" do %> - - <% end %> - - - + <%!-- Status icons --%>
<%= case @verified do %> <% :success -> %> @@ -128,24 +139,21 @@ defmodule PhoenixKitWeb.Components.Core.AWSRegionSelect do
<% end %>
- - + + <%!-- Helper text below the input/select --%> diff --git a/lib/phoenix_kit_web/live/modules/emails/settings.ex b/lib/phoenix_kit_web/live/modules/emails/settings.ex index 9207a4b47..77dc20242 100644 --- a/lib/phoenix_kit_web/live/modules/emails/settings.ex +++ b/lib/phoenix_kit_web/live/modules/emails/settings.ex @@ -104,6 +104,7 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do |> assign(:credential_verification_status, :pending) |> assign(:credential_verification_message, "") |> assign(:available_regions, []) + |> assign(:regions_loaded, false) |> assign(:selected_region, "") |> assign(:aws_permissions, %{}) @@ -637,83 +638,26 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do aws_settings = socket.assigns.aws_settings # Check if we have the required credentials - if String.trim(aws_settings.access_key_id) == "" or - String.trim(aws_settings.secret_access_key) == "" do - socket = - socket - |> assign(:credential_verification_status, :error) - |> assign( - :credential_verification_message, - "Please enter Access Key ID and Secret Access Key before verification." - ) - |> assign(:verifying_credentials, false) - - {:noreply, socket} + if credentials_missing?(aws_settings) do + {:noreply, + assign_verification_error( + socket, + "Please enter Access Key ID and Secret Access Key before verification." + )} else # Start verification socket = assign(socket, :verifying_credentials, true) # Run verification in a task to avoid blocking the LiveView - task = - Task.async(fn -> - # Verify credentials only (STS GetCallerIdentity) - # Actual permissions will be verified during "Setup AWS Infrastructure" - CredentialsVerifier.verify_credentials( - aws_settings.access_key_id, - aws_settings.secret_access_key, - aws_settings.region - ) - end) + task = Task.async(fn -> verify_aws_credentials(aws_settings) end) case Task.yield(task, 15_000) || Task.shutdown(task) do {:ok, result} -> - updated_socket = - case result do - {:ok, credential_info} -> - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :success) - |> assign( - :credential_verification_message, - "✅ Credentials verified! Account: #{credential_info.account_id}. Ready for Setup AWS Infrastructure." - ) - |> assign(:aws_permissions, %{}) - - # Note: Don't reload regions here - user's selected region should be preserved - # If user wants to see available regions, they can manually trigger refresh - - {:error, :invalid_credentials, message} -> - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :error) - |> assign(:credential_verification_message, "❌ Invalid credentials: #{message}") - - {:error, :authentication_failed, message} -> - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :error) - |> assign(:credential_verification_message, "❌ Authentication failed: #{message}") - - {:error, reason} -> - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :error) - |> assign(:credential_verification_message, "❌ Verification failed: #{reason}") - end - - {:noreply, updated_socket} + {:noreply, handle_verification_result(socket, result)} nil -> - socket = - socket - |> assign(:verifying_credentials, false) - |> assign(:credential_verification_status, :error) - |> assign( - :credential_verification_message, - "❌ Verification timed out. Please try again." - ) - - {:noreply, socket} + {:noreply, + assign_verification_error(socket, "❌ Verification timed out. Please try again.")} end end end @@ -745,6 +689,7 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do socket = socket |> assign(:available_regions, regions) + |> assign(:regions_loaded, true) |> assign(:selected_region, aws_settings.region) {:noreply, socket} @@ -752,6 +697,7 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do {:ok, {:error, reason}} -> socket = socket + |> assign(:regions_loaded, false) |> put_flash(:error, "Failed to load regions: #{reason}") {:noreply, socket} @@ -759,6 +705,7 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do nil -> socket = socket + |> assign(:regions_loaded, false) |> put_flash(:error, "Region loading timed out.") {:noreply, socket} @@ -955,6 +902,69 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do end end + # Private helpers for AWS credentials verification + + defp credentials_missing?(aws_settings) do + String.trim(aws_settings.access_key_id) == "" or + String.trim(aws_settings.secret_access_key) == "" + end + + defp verify_aws_credentials(aws_settings) do + # Verify credentials only (STS GetCallerIdentity) + # Actual permissions will be verified during "Setup AWS Infrastructure" + CredentialsVerifier.verify_credentials( + aws_settings.access_key_id, + aws_settings.secret_access_key, + aws_settings.region + ) + end + + defp assign_verification_error(socket, message) do + socket + |> assign(:verifying_credentials, false) + |> assign(:credential_verification_status, :error) + |> assign(:credential_verification_message, message) + end + + defp handle_verification_result(socket, {:ok, credential_info}) do + socket + |> assign(:verifying_credentials, false) + |> assign(:credential_verification_status, :success) + |> assign( + :credential_verification_message, + "✅ Credentials verified! Account: #{credential_info.account_id}. Ready for Setup AWS Infrastructure." + ) + |> assign(:aws_permissions, %{}) + end + + defp handle_verification_result(socket, {:error, :invalid_credentials, message}) do + assign_verification_error(socket, "❌ Invalid credentials: #{message}") + end + + defp handle_verification_result(socket, {:error, :authentication_failed, message}) do + assign_verification_error(socket, "❌ Authentication failed: #{message}") + end + + defp handle_verification_result(socket, {:error, :configuration_error, message}) do + assign_verification_error(socket, "❌ Configuration error: #{message}") + end + + defp handle_verification_result(socket, {:error, :rate_limited, message}) do + assign_verification_error(socket, "❌ Rate limited: #{message}") + end + + defp handle_verification_result(socket, {:error, :network_error, message}) do + assign_verification_error(socket, "❌ Network error: #{message}") + end + + defp handle_verification_result(socket, {:error, :response_error, message}) do + assign_verification_error(socket, "❌ Response parsing error: #{message}") + end + + defp handle_verification_result(socket, {:error, reason}) do + assign_verification_error(socket, "❌ Verification failed: #{reason}") + end + # Auto-load AWS regions when SES events are enabled defp maybe_auto_load_regions(socket, false), do: socket @@ -981,7 +991,9 @@ defmodule PhoenixKitWeb.Live.Modules.Emails.Settings do case Task.yield(task, 10_000) || Task.shutdown(task) do {:ok, {:ok, regions}} -> - assign(socket, :available_regions, regions) + socket + |> assign(:available_regions, regions) + |> assign(:regions_loaded, true) _ -> # Silently fail - user can manually refresh regions diff --git a/lib/phoenix_kit_web/live/modules/emails/settings.html.heex b/lib/phoenix_kit_web/live/modules/emails/settings.html.heex index 99a3cdb77..0a8b1b028 100644 --- a/lib/phoenix_kit_web/live/modules/emails/settings.html.heex +++ b/lib/phoenix_kit_web/live/modules/emails/settings.html.heex @@ -439,23 +439,34 @@ Enter Access Key ID (20 characters, e.g., AKIAIOSFODNN7EXAMPLE)
  • Enter Secret Access Key
  • +
  • Enter AWS Region manually (e.g., eu-north-1)
  • Click "Verify Credentials" to test connectivity
  • -
  • Select your AWS region from the dropdown
  • +
  • + Optional: + Click "Load regions" + to see all available regions +
  • - <%!-- Step 3: Save and Setup --%> + <%!-- Step 3: Setup Infrastructure --%>

    - 3. Save and Setup Infrastructure + 3. Setup Infrastructure

      -
    1. Click "Save AWS Settings" to persist configuration
    2. Click "Setup AWS Infrastructure" to create SNS/SQS resources
    3. Wait for setup completion (typically 30-60 seconds)
    4. +
    5. + All created resources will be automatically filled in the form below +
    6. +
    7. + Click "Save AWS Settings" + one more time to persist everything +
    @@ -579,6 +590,7 @@ name="aws_settings[region]" value={@aws_settings.region} regions={@available_regions} + regions_loaded={@regions_loaded} selected_region={@selected_region} verifying={@verifying_credentials} verified={@credential_verification_status} @@ -586,7 +598,7 @@ class="flex-1" /> - <%!-- Button to refresh regions --%> + <%!-- Button to load/refresh regions --%> - <%!-- Helper text for regions --%> - + <%!-- Helper text is now in the component itself --%> diff --git a/mix.exs b/mix.exs index fb5ed9280..6cd6d9ee0 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule PhoenixKit.MixProject do use Mix.Project - @version "1.6.3" + @version "1.6.4" @description "PhoenixKit is a starter kit for building modern web applications with Elixir and Phoenix" @source_url "https://github.com/BeamLabEU/phoenix_kit" @@ -90,6 +90,7 @@ defmodule PhoenixKit.MixProject do {:gen_smtp, "~> 1.2"}, # OAuth authentication + {:oauth2, "~> 2.0"}, {:ueberauth, "~> 0.10"}, {:ueberauth_google, "~> 0.12"}, {:ueberauth_apple, "~> 0.1"},