diff --git a/config/config.exs b/config/config.exs index bd6d36907..225190449 100644 --- a/config/config.exs +++ b/config/config.exs @@ -19,6 +19,9 @@ config :phoenix_kit, PhoenixKit.Mailer, adapter: Swoosh.Adapters.Local # Note: Hammer rate limiting configuration is automatically added to parent # applications via mix phoenix_kit.install/update tasks +# For standalone development, configure Hammer here: +config :hammer, + backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} # Configure Ueberauth (minimal configuration for compilation) # Applications using PhoenixKit should configure their own providers diff --git a/lib/mix/tasks/phoenix_kit.install.ex b/lib/mix/tasks/phoenix_kit.install.ex index 028e0de66..219f50965 100644 --- a/lib/mix/tasks/phoenix_kit.install.ex +++ b/lib/mix/tasks/phoenix_kit.install.ex @@ -57,6 +57,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do MailerConfig, MigrationStrategy, OAuthConfig, + ObanConfig, RateLimiterConfig, RepoDetection, RouterIntegration @@ -95,7 +96,9 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do |> MailerConfig.add_mailer_configuration() |> RateLimiterConfig.add_rate_limiter_configuration() |> OAuthConfig.add_oauth_configuration() + |> ObanConfig.add_oban_configuration() |> ApplicationSupervisor.add_supervisor() + |> ObanConfig.add_oban_supervisor() |> LayoutConfig.add_layout_integration_configuration() |> CssIntegration.add_automatic_css_integration() |> DemoFiles.copy_test_demo_files() @@ -274,6 +277,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do PhoenixKit requires configuration for: - Ueberauth (OAuth authentication) - Hammer (rate limiting) + - Oban (background jobs for file processing) This configuration will be added now. @@ -316,6 +320,10 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do !has_active_hammer_config?(lines) -> :missing + # Missing Oban configuration (check for active, non-commented config) + !has_active_oban_config?(lines) -> + :missing + # All required configuration present true -> :ok @@ -348,6 +356,26 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do has_hammer_config and has_expiry_ms end + # Check if active (non-commented) Oban configuration exists + defp has_active_oban_config?(lines) do + has_oban_config = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains config :phoenix_kit, Oban + !String.starts_with?(trimmed, "#") and + String.contains?(line, "config :phoenix_kit, Oban") + end) + + has_queues = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains queues: + !String.starts_with?(trimmed, "#") and String.contains?(line, "queues:") + end) + + has_oban_config and has_queues + 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 29b6393ef..23adbca9c 100644 --- a/lib/mix/tasks/phoenix_kit.update.ex +++ b/lib/mix/tasks/phoenix_kit.update.ex @@ -88,6 +88,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do BasicConfiguration, Common, CssIntegration, + ObanConfig, RateLimiterConfig } @@ -242,6 +243,10 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do !has_active_hammer_config?(lines) -> :missing + # Missing Oban configuration (check for active, non-commented config) + !has_active_oban_config?(lines) -> + :missing + # All required configuration present true -> :ok @@ -274,6 +279,26 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do has_hammer_config and has_expiry_ms end + # Check if active (non-commented) Oban configuration exists + defp has_active_oban_config?(lines) do + has_oban_config = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains config :phoenix_kit, Oban + !String.starts_with?(trimmed, "#") and + String.contains?(line, "config :phoenix_kit, Oban") + end) + + has_queues = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains queues: + !String.starts_with?(trimmed, "#") and String.contains?(line, "queues:") + end) + + has_oban_config and has_queues + end + # Perform the igniter-based update logic defp perform_igniter_update(igniter, opts) do prefix = opts[:prefix] || "public" @@ -285,6 +310,9 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do # Ensure Hammer rate limiter configuration exists igniter = validate_and_add_hammer_config(igniter) + # Ensure Oban configuration exists + igniter = validate_and_add_oban_config(igniter) + # Check if this is the first pass (config missing) or second pass (config exists) config_status = Process.get(:phoenix_kit_config_status, :ok) @@ -860,6 +888,53 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do Igniter.add_notice(igniter, String.trim(notice)) end + + # Validate and add Oban configuration if missing + defp validate_and_add_oban_config(igniter) do + config_exists = ObanConfig.oban_config_exists?(igniter) + supervisor_exists = ObanConfig.oban_supervisor_exists?(igniter) + + igniter = + if config_exists do + igniter + else + # Configuration missing, add it + igniter + |> ObanConfig.add_oban_configuration() + |> add_oban_config_added_notice() + end + + # Check and add supervisor separately + if supervisor_exists do + igniter + else + igniter + |> ObanConfig.add_oban_supervisor() + |> add_oban_supervisor_added_notice() + end + end + + # Add notice about Oban configuration being added + defp add_oban_config_added_notice(igniter) do + notice = """ + ⚠️ Added missing Oban configuration to config.exs + IMPORTANT: Restart your server if it's currently running. + Without Oban, the storage system cannot process uploaded files. + """ + + Igniter.add_notice(igniter, String.trim(notice)) + end + + # Add notice about Oban supervisor being added + defp add_oban_supervisor_added_notice(igniter) do + notice = """ + ⚠️ Added Oban to application supervisor tree in application.ex + IMPORTANT: Restart your server if it's currently running. + Oban will now start automatically with your application. + """ + + Igniter.add_notice(igniter, String.trim(notice)) + end end # Fallback module for when Igniter is not available diff --git a/lib/phoenix_kit/install/oban_config.ex b/lib/phoenix_kit/install/oban_config.ex new file mode 100644 index 000000000..1ad5f7adb --- /dev/null +++ b/lib/phoenix_kit/install/oban_config.ex @@ -0,0 +1,355 @@ +defmodule PhoenixKit.Install.ObanConfig do + @moduledoc """ + Handles Oban configuration for PhoenixKit installation. + + This module provides functionality to: + - Configure Oban for background job processing + - Set up required queues (default, emails, file_processing) + - Add Oban.Plugins.Pruner for job cleanup + - Add Oban to application supervisor tree + - Ensure configuration exists during updates + """ + use PhoenixKit.Install.IgniterCompat + + alias PhoenixKit.Install.IgniterHelpers + + @doc """ + Adds or verifies Oban configuration. + + This function ensures that Oban is properly configured for PhoenixKit's + background job processing, including: + 1. Repo configuration (auto-detected from PhoenixKit config) + 2. Required queues for file processing and email handling + 3. Pruner plugin for automatic job cleanup + + ## Parameters + - `igniter` - The igniter context + + ## Returns + Updated igniter with Oban configuration and notices. + """ + def add_oban_configuration(igniter) do + igniter + |> add_oban_config() + |> add_oban_configuration_notice() + end + + @doc """ + Checks if Oban configuration exists in config.exs. + + ## Parameters + - `igniter` - The igniter context for detecting parent app name + + ## Returns + Boolean indicating if configuration exists. + """ + def oban_config_exists?(igniter) do + config_path = "config/config.exs" + app_name = IgniterHelpers.get_parent_app_name(igniter) + + if File.exists?(config_path) do + content = File.read!(config_path) + lines = String.split(content, "\n") + + # Check for active (non-commented) Oban configuration with parent app namespace + has_oban_config = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains config :app_name, Oban + !String.starts_with?(trimmed, "#") and + String.contains?(line, "config :#{app_name}, Oban") + end) + + has_queues = + Enum.any?(lines, fn line -> + trimmed = String.trim(line) + # Not a comment and contains queues: + !String.starts_with?(trimmed, "#") and String.contains?(line, "queues:") + end) + + has_oban_config and has_queues + else + false + end + rescue + _ -> false + end + + # Add Oban configuration to config.exs + defp add_oban_config(igniter) do + # Get parent app name and repo + app_name = IgniterHelpers.get_parent_app_name(igniter) + repo_module = get_repo_module(igniter) + + oban_config = """ + + # Configure Oban for PhoenixKit background jobs + # Required for file processing (storage system) and email handling + config :#{app_name}, Oban, + repo: #{repo_module}, + queues: [ + default: 10, # General purpose queue + emails: 50, # Email processing + file_processing: 20 # File variant generation (storage system) + ], + plugins: [ + Oban.Plugins.Pruner # Automatic cleanup of completed jobs + ] + """ + + try do + Igniter.update_file(igniter, "config/config.exs", fn source -> + content = Rewrite.Source.get(source, :content) + + # Check if Oban config already exists + if String.contains?(content, "config :#{app_name}, Oban") do + source + else + # Find insertion point before import_config statements + insertion_point = find_import_config_location(content) + + updated_content = + case insertion_point do + {:before_import, before_content, after_content} -> + # Insert before import_config + before_content <> oban_config <> "\n" <> after_content + + :append_to_end -> + # No import_config found, append to end + content <> oban_config + end + + Rewrite.Source.update(source, :content, updated_content) + end + end) + rescue + e -> + IO.warn("Failed to add Oban configuration: #{inspect(e)}") + add_manual_config_notice(igniter, repo_module) + end + end + + # Get repo module from PhoenixKit config or use placeholder + defp get_repo_module(_igniter) do + config_path = "config/config.exs" + + if File.exists?(config_path) do + content = File.read!(config_path) + + # Look for existing PhoenixKit repo config + case Regex.run(~r/config :phoenix_kit,\s+repo:\s+([A-Za-z0-9_.]+)/, content) do + [_, repo] -> repo + _ -> "MyApp.Repo" + end + else + "MyApp.Repo" + end + rescue + _ -> "MyApp.Repo" + end + + # Find the location to insert config before import_config statements + defp find_import_config_location(content) do + lines = String.split(content, "\n") + + # Look for import_config pattern + 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) + + case import_index do + nil -> + # No import_config found, append to end + :append_to_end + + index -> + # Find the start of the import_config block + start_index = find_import_block_start(lines, index) + + # Split content at the start of import block + before_lines = Enum.take(lines, start_index) + after_lines = Enum.drop(lines, start_index) + + before_content = Enum.join(before_lines, "\n") + after_content = Enum.join(after_lines, "\n") + + {:before_import, before_content, after_content} + end + end + + # Find the start of the import_config block (including preceding comments) + defp find_import_block_start(lines, import_index) do + lines + |> Enum.take(import_index) + |> Enum.reverse() + |> Enum.reduce_while(import_index, fn line, current_index -> + trimmed = String.trim(line) + + cond do + # Comment line related to import + String.starts_with?(trimmed, "#") and + (String.contains?(line, "import") or String.contains?(line, "Import") or + String.contains?(line, "bottom") or String.contains?(line, "BOTTOM") or + String.contains?(line, "environment")) -> + {:cont, current_index - 1} + + # Blank line + trimmed == "" -> + {:cont, current_index - 1} + + # config_env or similar + String.contains?(line, "config_env()") or String.contains?(line, "env_config") -> + {:cont, current_index - 1} + + # Stop at any other code + true -> + {:halt, current_index} + end + end) + end + + # Add notice about Oban configuration + defp add_oban_configuration_notice(igniter) do + if oban_config_exists?(igniter) do + Igniter.add_notice( + igniter, + "⚙️ Oban configured for background jobs (file processing, emails)" + ) + else + Igniter.add_notice( + igniter, + "⚠️ Oban configuration added - restart your server if running" + ) + end + end + + @doc """ + Adds Oban to the parent application's supervision tree. + + This function ensures that Oban starts automatically when the application starts, + positioned after PhoenixKit.Supervisor to ensure PhoenixKit services are available + before Oban workers run. + + ## Parameters + - `igniter` - The igniter context + + ## Returns + Updated igniter with Oban added to application supervisor. + """ + def add_oban_supervisor(igniter) do + app_name = IgniterHelpers.get_parent_app_name(igniter) + app_file = "lib/#{app_name}/application.ex" + oban_line = " {Oban, Application.get_env(:#{app_name}, Oban)}," + + Igniter.update_file(igniter, app_file, fn source -> + update_application_with_oban(source, app_name, oban_line) + end) + end + + # Update application.ex source with Oban supervisor + defp update_application_with_oban(source, app_name, oban_line) do + content = Rewrite.Source.get(source, :content) + + if oban_already_configured?(content, app_name) do + source + else + updated_content = insert_or_replace_oban(content, oban_line) + Rewrite.Source.update(source, :content, updated_content) + end + end + + # Check if Oban is already properly configured + defp oban_already_configured?(content, app_name) do + String.contains?(content, "{Oban, Application.get_env(:#{app_name}, Oban)}") + end + + # Insert or replace Oban configuration in application.ex + defp insert_or_replace_oban(content, oban_line) do + lines = String.split(content, "\n") + has_bare_oban = Enum.any?(lines, fn line -> String.trim(line) == "Oban," end) + + updated_lines = process_application_lines(lines, oban_line, has_bare_oban) + Enum.join(updated_lines, "\n") + end + + # Process each line to insert/replace Oban + defp process_application_lines(lines, oban_line, has_bare_oban) do + Enum.reduce(lines, [], fn line, acc -> + trimmed = String.trim(line) + + cond do + # Replace bare "Oban," with proper config + trimmed == "Oban," -> + acc ++ [oban_line] + + # Insert Oban after PhoenixKit.Supervisor ONLY if there's no bare Oban to replace + String.contains?(line, "PhoenixKit.Supervisor") and not has_bare_oban -> + acc ++ [line, oban_line] + + true -> + acc ++ [line] + end + end) + end + + @doc """ + Checks if Oban supervisor is configured in application.ex. + + ## Parameters + - `igniter` - The igniter context for detecting parent app name + + ## Returns + Boolean indicating if Oban supervisor exists in application.ex. + """ + def oban_supervisor_exists?(igniter) do + app_name = IgniterHelpers.get_parent_app_name(igniter) + app_file = "lib/#{app_name}/application.ex" + + if File.exists?(app_file) do + content = File.read!(app_file) + + # Check for Oban in children list + String.contains?(content, "{Oban,") or + String.contains?(content, "Application.get_env(:#{app_name}, Oban)") + else + false + end + rescue + _ -> false + end + + # Add notice when manual configuration is required + defp add_manual_config_notice(igniter, repo_module) do + app_name = IgniterHelpers.get_parent_app_name(igniter) + + notice = """ + ⚠️ Manual Configuration Required: Oban + + PhoenixKit couldn't automatically configure Oban for background jobs. + + Please add the following to config/config.exs: + + config :#{app_name}, Oban, + repo: #{repo_module}, + queues: [ + default: 10, + emails: 50, + file_processing: 20 + ], + plugins: [ + Oban.Plugins.Pruner + ] + + And add the following to lib/#{app_name}/application.ex in the children list: + + {Oban, Application.get_env(:#{app_name}, Oban)} + + Without this configuration, the storage system cannot process uploaded files. + Files will remain stuck in "processing" status. + """ + + Igniter.add_notice(igniter, notice) + end +end diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index 5208fa954..811ce0cfa 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -170,7 +170,7 @@ defmodule PhoenixKit.Migrations.Postgres do - Per-dimension control for responsive sizing vs exact crops - Defaults to maintaining aspect ratio for all dimensions - ### V26 - Rename Checksum Fields & Per-User Deduplication ⚡ LATEST + ### V26 - Rename Checksum Fields & Per-User Deduplication - Renames `checksum` to `file_checksum` (clearer naming) - Removes unique index on file_checksum (allows same file from different users) - Adds `user_file_checksum` column (SHA256 of user_id + file_checksum) @@ -180,19 +180,30 @@ defmodule PhoenixKit.Migrations.Postgres do - Preserves file_checksum field for popularity analytics across all users - Clearer naming convention: file_checksum vs user_file_checksum + ### V27 - Oban Background Job System ⚡ LATEST + - Creates Oban tables for background job processing + - Oban_jobs table for job queue management + - Oban_peers table for distributed coordination + - Performance indexes for efficient job processing + - Enables file processing (variant generation, metadata extraction) + - Enables email processing (sending, tracking, analytics) + - Uses Oban's latest schema version automatically (forward-compatible) + - Integrated with PhoenixKit configuration system + ## Migration Paths ### Fresh Installation (0 → Current) - Runs all migrations V01 through V26 in sequence. + Runs all migrations V01 through V27 in sequence. ### Incremental Updates - - V01 → V26: Runs V02 through V26 in sequence - - V25 → V26: Runs V26 only (adds per-user file hash) - - V24 → V26: Runs V25 and V26 in sequence - - V23 → V26: Runs V24, V25, and V26 in sequence - - V20 → V26: Runs V21 through V26 in sequence + - V01 → V27: Runs V02 through V27 in sequence + - V26 → V27: Runs V27 only (adds Oban tables) + - V25 → V27: Runs V26 and V27 in sequence + - V24 → V27: Runs V25, V26, and V27 in sequence + - V20 → V27: Runs V21 through V27 in sequence ### Rollback Support + - V27 → V26: Removes Oban tables and background job system - V26 → V25: Removes user_file_checksum, renames file_checksum back to checksum, restores checksum unique index - V25 → V24: Removes aspect ratio control from dimensions - V24 → V23: Removes unique index on checksum @@ -212,14 +223,14 @@ defmodule PhoenixKit.Migrations.Postgres do ## Usage Examples - # Update to latest version (V26) + # Update to latest version (V27) PhoenixKit.Migrations.Postgres.up(prefix: "myapp") # Update to specific version - PhoenixKit.Migrations.Postgres.up(prefix: "myapp", version: 26) + PhoenixKit.Migrations.Postgres.up(prefix: "myapp", version: 27) # Rollback to specific version - PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 25) + PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 26) # Complete rollback PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 0) @@ -237,7 +248,7 @@ defmodule PhoenixKit.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 26 + @current_version 27 @default_prefix "public" @doc false diff --git a/lib/phoenix_kit/migrations/postgres/v20.ex b/lib/phoenix_kit/migrations/postgres/v20.ex index d7ea12b14..d4f8245f8 100644 --- a/lib/phoenix_kit/migrations/postgres/v20.ex +++ b/lib/phoenix_kit/migrations/postgres/v20.ex @@ -357,8 +357,8 @@ defmodule PhoenixKit.Migrations.Postgres.V20 do execute """ INSERT INTO #{prefix_table_name("phoenix_kit_buckets", prefix)} - (id, name, provider, enabled, priority, inserted_at, updated_at) - VALUES ('#{id}', 'Local Storage', 'local', true, 0, '#{now}', '#{now}') + (id, name, provider, endpoint, enabled, priority, inserted_at, updated_at) + VALUES ('#{id}', 'Local Storage', 'local', 'priv/media', true, 0, '#{now}', '#{now}') """ end diff --git a/lib/phoenix_kit/migrations/postgres/v27.ex b/lib/phoenix_kit/migrations/postgres/v27.ex new file mode 100644 index 000000000..5c46343c3 --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v27.ex @@ -0,0 +1,67 @@ +defmodule PhoenixKit.Migrations.Postgres.V27 do + @moduledoc """ + Migration V27: Add Oban tables for background job processing. + + This migration creates Oban tables required for background job processing, + including file processing (storage system) and email handling. + + ## Changes + - Creates `oban_jobs` table for job queue management + - Creates `oban_peers` table for distributed coordination + - Adds indexes for efficient job processing + - Sets up Oban schema to latest version (uses Oban.Migration.up/1) + + ## Requirements + - PostgreSQL database + - Oban dependency (`{:oban, "~> 2.17"}`) + + ## Purpose + - Enable background job processing for: + - File variant generation (thumbnails, resizes) + - Video processing (transcoding, thumbnails) + - Email sending and tracking + - Metadata extraction (dimensions, duration, EXIF) + - Multi-bucket redundancy uploads + + ## Queue Configuration + After running this migration, configure Oban queues in config/config.exs: + + config :phoenix_kit, Oban, + repo: MyApp.Repo, + queues: [ + default: 10, + emails: 50, + file_processing: 20 + ], + plugins: [Oban.Plugins.Pruner] + + ## Notes + - Oban tables are created in the same schema prefix as PhoenixKit tables + - Uses Oban's latest schema version automatically (forward-compatible) + - Idempotent: Safe to run multiple times + """ + + use Ecto.Migration + + def up(%{prefix: prefix} = _opts) do + # Run Oban migrations to create required tables + # Uses latest Oban schema version automatically (forward-compatible) + Oban.Migration.up(prefix: prefix) + + # Set version comment on phoenix_kit table for version tracking + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '27'" + end + + def down(%{prefix: prefix} = _opts) do + # Remove Oban tables by downgrading to version 1 (minimum required version) + Oban.Migration.down(prefix: prefix, version: 1) + + # Update version comment on phoenix_kit table to previous version + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '26'" + end + + # Helper functions + + defp prefix_table_name(table_name, nil), do: table_name + defp prefix_table_name(table_name, prefix), do: "#{prefix}.#{table_name}" +end diff --git a/lib/phoenix_kit/storage.ex b/lib/phoenix_kit/storage.ex index 6ecd379df..63f13997f 100644 --- a/lib/phoenix_kit/storage.ex +++ b/lib/phoenix_kit/storage.ex @@ -897,6 +897,32 @@ defmodule PhoenixKit.Storage do ext, original_filename \\ nil ) do + # Check if any enabled buckets exist + case list_enabled_buckets() do + [] -> + {:error, :no_buckets_configured} + + _buckets -> + # Proceed with storage + store_file_with_buckets_available( + source_path, + file_type, + user_id, + file_checksum, + ext, + original_filename + ) + end + end + + defp store_file_with_buckets_available( + source_path, + file_type, + user_id, + file_checksum, + ext, + original_filename + ) do # Calculate user-specific hash for duplicate detection user_file_checksum = calculate_user_file_checksum(user_id, file_checksum) diff --git a/lib/phoenix_kit/storage/providers/local.ex b/lib/phoenix_kit/storage/providers/local.ex index 83b5e0db4..934968d44 100644 --- a/lib/phoenix_kit/storage/providers/local.ex +++ b/lib/phoenix_kit/storage/providers/local.ex @@ -11,7 +11,7 @@ defmodule PhoenixKit.Storage.Providers.Local do @impl true def store_file(bucket, source_path, destination_path, _opts \\ []) do # Build the full destination path - full_destination = Path.join(bucket.endpoint || "priv/uploads", destination_path) + full_destination = Path.join(bucket.endpoint || "priv/media", destination_path) # Ensure directory exists destination_dir = Path.dirname(full_destination) @@ -35,7 +35,7 @@ defmodule PhoenixKit.Storage.Providers.Local do @impl true def retrieve_file(bucket, file_path, destination_path) do - full_source = Path.join(bucket.endpoint || "priv/uploads", file_path) + full_source = Path.join(bucket.endpoint || "priv/media", file_path) # Ensure destination directory exists destination_dir = Path.dirname(destination_path) @@ -56,7 +56,7 @@ defmodule PhoenixKit.Storage.Providers.Local do @impl true def delete_file(bucket, file_path) do - full_path = Path.join(bucket.endpoint || "priv/uploads", file_path) + full_path = Path.join(bucket.endpoint || "priv/media", file_path) case File.rm(full_path) do :ok -> :ok @@ -70,7 +70,7 @@ defmodule PhoenixKit.Storage.Providers.Local do @impl true def file_exists?(bucket, file_path) do - full_path = Path.join(bucket.endpoint || "priv/uploads", file_path) + full_path = Path.join(bucket.endpoint || "priv/media", file_path) File.exists?(full_path) end @@ -83,7 +83,7 @@ defmodule PhoenixKit.Storage.Providers.Local do @impl true def test_connection(bucket) do - base_path = bucket.endpoint || "priv/uploads" + base_path = bucket.endpoint || "priv/media" # Test if we can create the directory case File.mkdir_p(base_path) do diff --git a/lib/phoenix_kit/supervisor.ex b/lib/phoenix_kit/supervisor.ex index 28d0403bd..ba0447aba 100644 --- a/lib/phoenix_kit/supervisor.ex +++ b/lib/phoenix_kit/supervisor.ex @@ -15,6 +15,8 @@ defmodule PhoenixKit.Supervisor do PhoenixKit.Admin.SimplePresence, {PhoenixKit.Cache.Registry, []}, {PhoenixKit.Cache, name: :settings, warmer: &PhoenixKit.Settings.warm_cache_data/0}, + # Rate limiter backend MUST be started before any authentication requests + PhoenixKit.Users.RateLimiter.Backend, # OAuth config loader MUST be first to ensure configuration # is available before any OAuth requests are processed PhoenixKit.Workers.OAuthConfigLoader, diff --git a/lib/phoenix_kit/users/auth/user.ex b/lib/phoenix_kit/users/auth/user.ex index 421f35462..2349ff848 100644 --- a/lib/phoenix_kit/users/auth/user.ex +++ b/lib/phoenix_kit/users/auth/user.ex @@ -333,11 +333,12 @@ defmodule PhoenixKit.Users.Auth.User do """ def profile_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:first_name, :last_name, :email, :username, :user_timezone]) + |> cast(attrs, [:first_name, :last_name, :email, :username, :user_timezone, :custom_fields]) |> validate_names() |> validate_email(opts) |> validate_username(opts) |> validate_user_timezone() + |> validate_custom_fields() end @doc """ diff --git a/lib/phoenix_kit/users/custom_fields.ex b/lib/phoenix_kit/users/custom_fields.ex index cb8170384..a01d1f16f 100644 --- a/lib/phoenix_kit/users/custom_fields.ex +++ b/lib/phoenix_kit/users/custom_fields.ex @@ -17,6 +17,7 @@ defmodule PhoenixKit.Users.CustomFields do - `required` - Whether the field is required (boolean) - `position` - Display order (integer) - `enabled` - Whether the field is active (boolean) + - `user_accessible` - Whether users can edit this field from their settings page (boolean, default: true) - `validation` - Optional validation rules (map) - `default` - Default value (string) - `options` - For select/radio/checkbox types (list of strings) @@ -101,6 +102,33 @@ defmodule PhoenixKit.Users.CustomFields do |> Enum.sort_by(&(&1["position"] || 0)) end + @doc """ + Returns only enabled field definitions that are user-accessible, sorted by position. + + These are fields that users can view and edit from their own settings page. + Admins can always see and edit all fields regardless of this setting. + + Legacy fields without the `user_accessible` key default to `true` (accessible). + + ## Examples + + iex> list_user_accessible_field_definitions() + [%{"key" => "phone", "enabled" => true, "user_accessible" => true, ...}] + """ + def list_user_accessible_field_definitions do + list_field_definitions() + |> Enum.filter(fn field -> + # Field must be enabled + enabled = field["enabled"] == true + + # user_accessible defaults to true if not set (for legacy fields) + user_accessible = Map.get(field, "user_accessible", true) + + enabled && user_accessible + end) + |> Enum.sort_by(&(&1["position"] || 0)) + end + @doc """ Gets a single field definition by key. @@ -336,15 +364,28 @@ defmodule PhoenixKit.Users.CustomFields do "key" => field_def["key"], "label" => field_def["label"] || field_def["key"], "type" => field_def["type"] || "text", - "required" => field_def["required"] || false, + "required" => normalize_boolean(field_def["required"], false), "position" => field_def["position"] || 0, - "enabled" => Map.get(field_def, "enabled", true), + "enabled" => normalize_boolean(Map.get(field_def, "enabled"), true), + "user_accessible" => normalize_boolean(Map.get(field_def, "user_accessible"), true), "validation" => field_def["validation"] || %{}, "default" => field_def["default"] || "", "options" => field_def["options"] || [] } end + # Convert string boolean values to actual booleans + defp normalize_boolean(value, default) do + case value do + true -> true + false -> false + "true" -> true + "false" -> false + nil -> default + _ -> default + end + end + defp ensure_unique_key(key) do if get_field_definition(key) do {:error, "Field with key '#{key}' already exists"} diff --git a/lib/phoenix_kit/users/roles.ex b/lib/phoenix_kit/users/roles.ex index d269f49fb..968de28d6 100644 --- a/lib/phoenix_kit/users/roles.ex +++ b/lib/phoenix_kit/users/roles.ex @@ -712,22 +712,27 @@ defmodule PhoenixKit.Users.Roles do roles = Role.system_roles() repo.transaction(fn -> - # Lock the Owner role AND count existing active owners in a single atomic query - # This prevents race conditions during concurrent user registrations - result = + # Lock the Owner role to prevent race conditions during concurrent user registrations + # Note: Cannot use FOR UPDATE with aggregate functions, so we split into two queries + owner_role = repo.one( from r in Role, - left_join: assignment in RoleAssignment, - on: assignment.role_id == r.id, - left_join: u in User, - on: assignment.user_id == u.id and u.is_active == true, where: r.name == ^roles.owner, - lock: "FOR UPDATE", - select: {r, count(u.id)} + lock: "FOR UPDATE" + ) + + # Count existing active owners in a separate query (within the same transaction) + owner_count = + repo.one( + from assignment in RoleAssignment, + join: u in User, + on: assignment.user_id == u.id and u.is_active == true, + where: assignment.role_id == ^owner_role.id, + select: count(u.id) ) - case result do - {_owner_role, 0} -> + case owner_count do + 0 -> # No active owners exist, make this user Owner case assign_role_internal(user, roles.owner) do {:ok, _assignment} -> @@ -738,7 +743,7 @@ defmodule PhoenixKit.Users.Roles do repo.rollback(reason) end - {_owner_role, _count} -> + count when is_integer(count) and count > 0 -> # Active owners exist, assign default role default_role_name = get_safe_default_role() diff --git a/lib/phoenix_kit/utils/date.ex b/lib/phoenix_kit/utils/date.ex index b3cf65438..b08d2b519 100644 --- a/lib/phoenix_kit/utils/date.ex +++ b/lib/phoenix_kit/utils/date.ex @@ -47,17 +47,16 @@ defmodule PhoenixKit.Utils.Date do ## Implementation - This module uses Timex for robust internationalized date/time formatting - with extensive format support and proper locale handling. + This module uses Elixir's built-in Calendar.strftime for robust date/time formatting + with extensive format support. """ - use Timex alias PhoenixKit.Settings @doc """ Formats a date according to the specified format string. - Uses Timex for robust date formatting with extensive format support. + Uses Calendar.strftime for robust date formatting with extensive format support. ## Examples @@ -71,37 +70,14 @@ defmodule PhoenixKit.Utils.Date do "January 15, 2024" """ def format_date(date, format) do - timex_format = get_timex_format(format) - format_with_timex(date, timex_format) - end - - # Private helper functions to reduce complexity - defp get_timex_format(format) do - case format do - "Y-m-d" -> "{YYYY}-{0M}-{0D}" - "m/d/Y" -> "{0M}/{0D}/{YYYY}" - "d/m/Y" -> "{0D}/{0M}/{YYYY}" - "d.m.Y" -> "{0D}.{0M}.{YYYY}" - "d.m" -> "{0D}.{0M}" - "d-m-Y" -> "{0D}-{0M}-{YYYY}" - "F j, Y" -> "{Mfull} {D}, {YYYY}" - # Default to Y-m-d format - _ -> "{YYYY}-{0M}-{0D}" - end - end - - defp format_with_timex(date, timex_format) do - case Timex.format(date, timex_format) do - {:ok, formatted} -> formatted - # Fallback to ISO format - {:error, _} -> Date.to_string(date) - end + strftime_format = convert_php_to_strftime(format, :date) + Calendar.strftime(date, strftime_format) end @doc """ Formats a time according to the specified format string. - Uses Timex for robust time formatting with extensive format support. + Uses Calendar.strftime for robust time formatting with extensive format support. ## Examples @@ -112,19 +88,32 @@ defmodule PhoenixKit.Utils.Date do "3:30 PM" """ def format_time(time, format) do - # Map our format codes to Timex format strings - timex_format = - case format do - "H:i" -> "{h24}:{m}" - "h:i A" -> "{h12}:{m} {AM}" - # Default to 24-hour format - _ -> "{h24}:{m}" - end - - case Timex.format(time, timex_format) do - {:ok, formatted} -> formatted - # Fallback to ISO format - {:error, _} -> Time.to_string(time) + strftime_format = convert_php_to_strftime(format, :time) + Calendar.strftime(time, strftime_format) + end + + # Convert PHP-style format strings to strftime format strings + defp convert_php_to_strftime(format, :date) do + case format do + "Y-m-d" -> "%Y-%m-%d" + "m/d/Y" -> "%m/%d/%Y" + "d/m/Y" -> "%d/%m/%Y" + "d.m.Y" -> "%d.%m.%Y" + "d.m" -> "%d.%m" + "d-m-Y" -> "%d-%m-%Y" + "F j, Y" -> "%B %-d, %Y" + # Default to Y-m-d format + _ -> "%Y-%m-%d" + end + end + + # Convert PHP-style time format strings to strftime format strings + defp convert_php_to_strftime(format, :time) do + case format do + "H:i" -> "%H:%M" + "h:i A" -> "%I:%M %p" + # Default to 24-hour format + _ -> "%H:%M" end end diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index efae038b9..52779b087 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -246,7 +246,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do disable_active={true} /> - <%= if submenu_open?(@current_path, ["/admin/users", "/admin/users/live_sessions", "/admin/users/sessions", "/admin/users/roles", "/admin/users/referral-codes", "/admin/users/media"]) do %> + <%= if submenu_open?(@current_path, ["/admin/users", "/admin/users/live_sessions", "/admin/users/sessions", "/admin/users/roles", "/admin/users/referral-codes"]) do %> <%!-- Submenu items --%>
<.admin_nav_item @@ -281,14 +281,6 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do nested={true} /> - <.admin_nav_item - href={Routes.locale_aware_path(assigns, "/admin/users/media")} - icon="photo" - label="Media" - current_path={@current_path || ""} - nested={true} - /> - <%= if PhoenixKit.ReferralCodes.enabled?() do %> <.admin_nav_item href={Routes.locale_aware_path(assigns, "/admin/users/referral-codes")} @@ -301,6 +293,14 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
<% end %> + <%!-- Media as top-level menu item --%> + <.admin_nav_item + href={Routes.locale_aware_path(assigns, "/admin/media")} + icon="photo" + label="Media" + current_path={@current_path || ""} + /> + <%= if PhoenixKit.Emails.enabled?() do %> <%!-- Email section with direct link and conditional submenu --%> <.admin_nav_item @@ -438,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", "/admin/settings/seo"]) 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/media", "/admin/settings/storage/dimensions", "/admin/settings/maintenance", "/admin/settings/blogging", "/admin/settings/seo"]) do %> <%!-- Settings submenu items --%>
<.admin_nav_item @@ -519,20 +519,20 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do /> <% end %> - <%!-- Storage section with submenu --%> + <%!-- Media section with submenu --%> <.admin_nav_item - href={Routes.locale_aware_path(assigns, "/admin/settings/storage")} + href={Routes.locale_aware_path(assigns, "/admin/settings/media")} icon="storage" - label="Storage" + label="Media" current_path={@current_path || ""} nested={true} /> - <%= if submenu_open?(@current_path, ["/admin/settings/storage", "/admin/settings/storage/dimensions"]) do %> + <%= if submenu_open?(@current_path, ["/admin/settings/media", "/admin/settings/media/dimensions"]) do %> <%!-- Storage submenu items --%>
<.admin_nav_item - href={Routes.locale_aware_path(assigns, "/admin/settings/storage/dimensions")} + href={Routes.locale_aware_path(assigns, "/admin/settings/media/dimensions")} icon="photo" label="Dimensions" current_path={@current_path || ""} @@ -843,7 +843,12 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do defp looks_like_locale?(locale), do: String.length(locale) <= 3 defp path_matches_any?(normalized_path, paths) do - Enum.any?(paths, &String.starts_with?(normalized_path, &1)) + Enum.any?(paths, fn path -> + # Exact match or path segment match (followed by / or query string) + normalized_path == path || + String.starts_with?(normalized_path, path <> "/") || + String.starts_with?(normalized_path, path <> "?") + end) end # Render with parent application layout (Phoenix v1.8+ function component approach) diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 08f60a325..8d294f5b8 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -284,8 +284,8 @@ defmodule PhoenixKitWeb.Integration do live "/admin/users/roles", Live.Users.Roles, :index live "/admin/users/live_sessions", Live.Users.LiveSessions, :index live "/admin/users/sessions", Live.Users.Sessions, :index - live "/admin/users/media", Live.Users.Media, :index - live "/admin/users/media/:file_id", Live.Users.MediaDetail, :show + live "/admin/media", Live.Users.Media, :index + live "/admin/media/:file_id", Live.Users.MediaDetail, :show live "/admin/media/selector", Live.Users.MediaSelector, :index live "/admin/settings", Live.Settings, :index live "/admin/settings/users", Live.Settings.Users, :index @@ -308,20 +308,20 @@ defmodule PhoenixKitWeb.Integration do live "/admin/settings/seo", Live.Settings.SEO, :index - live "/admin/settings/storage", Live.Settings.Storage, :index - live "/admin/settings/storage/buckets/new", Live.Settings.Storage.BucketForm, :new - live "/admin/settings/storage/buckets/:id/edit", Live.Settings.Storage.BucketForm, :edit - live "/admin/settings/storage/dimensions", Live.Settings.Storage.Dimensions, :index + live "/admin/settings/media", Live.Settings.Storage, :index + live "/admin/settings/media/buckets/new", Live.Settings.Storage.BucketForm, :new + live "/admin/settings/media/buckets/:id/edit", Live.Settings.Storage.BucketForm, :edit + live "/admin/settings/media/dimensions", Live.Settings.Storage.Dimensions, :index - live "/admin/settings/storage/dimensions/new/image", + live "/admin/settings/media/dimensions/new/image", Live.Settings.Storage.DimensionForm, :new_image - live "/admin/settings/storage/dimensions/new/video", + live "/admin/settings/media/dimensions/new/video", Live.Settings.Storage.DimensionForm, :new_video - live "/admin/settings/storage/dimensions/:id/edit", + live "/admin/settings/media/dimensions/:id/edit", Live.Settings.Storage.DimensionForm, :edit @@ -418,8 +418,8 @@ defmodule PhoenixKitWeb.Integration do live "/admin/users/roles", Live.Users.Roles, :index live "/admin/users/live_sessions", Live.Users.LiveSessions, :index live "/admin/users/sessions", Live.Users.Sessions, :index - live "/admin/users/media", Live.Users.Media, :index - live "/admin/users/media/:file_id", Live.Users.MediaDetail, :show + live "/admin/media", Live.Users.Media, :index + live "/admin/media/:file_id", Live.Users.MediaDetail, :show live "/admin/media/selector", Live.Users.MediaSelector, :index live "/admin/settings", Live.Settings, :index live "/admin/settings/users", Live.Settings.Users, :index @@ -442,20 +442,20 @@ defmodule PhoenixKitWeb.Integration do live "/admin/settings/seo", Live.Settings.SEO, :index - live "/admin/settings/storage", Live.Settings.Storage, :index - live "/admin/settings/storage/buckets/new", Live.Settings.Storage.BucketForm, :new - live "/admin/settings/storage/buckets/:id/edit", Live.Settings.Storage.BucketForm, :edit - live "/admin/settings/storage/dimensions", Live.Settings.Storage.Dimensions, :index + live "/admin/settings/media", Live.Settings.Storage, :index + live "/admin/settings/media/buckets/new", Live.Settings.Storage.BucketForm, :new + live "/admin/settings/media/buckets/:id/edit", Live.Settings.Storage.BucketForm, :edit + live "/admin/settings/media/dimensions", Live.Settings.Storage.Dimensions, :index - live "/admin/settings/storage/dimensions/new/image", + live "/admin/settings/media/dimensions/new/image", Live.Settings.Storage.DimensionForm, :new_image - live "/admin/settings/storage/dimensions/new/video", + live "/admin/settings/media/dimensions/new/video", Live.Settings.Storage.DimensionForm, :new_video - live "/admin/settings/storage/dimensions/:id/edit", + live "/admin/settings/media/dimensions/:id/edit", Live.Settings.Storage.DimensionForm, :edit diff --git a/lib/phoenix_kit_web/live/components/media_selector_modal.ex b/lib/phoenix_kit_web/live/components/media_selector_modal.ex index 81f6fa1ae..a26d9b42c 100644 --- a/lib/phoenix_kit_web/live/components/media_selector_modal.ex +++ b/lib/phoenix_kit_web/live/components/media_selector_modal.ex @@ -49,10 +49,14 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do @per_page 30 def update(assigns, socket) do + # Check if any enabled buckets exist + enabled_buckets = Storage.list_enabled_buckets() + has_buckets = length(enabled_buckets) > 0 + socket = socket |> assign(assigns) - |> assign_new(:selected_ids, fn -> MapSet.new(assigns[:selected_ids] || []) end) + |> assign(:has_buckets, has_buckets) |> assign_new(:file_type_filter, fn -> :all end) |> assign_new(:search_query, fn -> "" end) |> assign_new(:current_page, fn -> 1 end) @@ -60,7 +64,15 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do |> assign_new(:uploaded_files, fn -> [] end) |> assign_new(:total_count, fn -> 0 end) |> assign_new(:total_pages, fn -> 0 end) - |> maybe_allow_upload() + |> maybe_allow_upload(has_buckets) + + # Convert selected_ids to MapSet if it's a list + socket = + if socket.assigns[:selected_ids] && is_list(socket.assigns.selected_ids) do + assign(socket, :selected_ids, MapSet.new(socket.assigns.selected_ids)) + else + assign_new(socket, :selected_ids, fn -> MapSet.new([]) end) + end # Load files if modal is shown socket = @@ -79,16 +91,22 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do {:ok, socket} end - defp maybe_allow_upload(socket) do - if socket.assigns[:uploads] do - socket - else - allow_upload(socket, :media_files, - accept: :any, - max_entries: 10, - auto_upload: true, - progress: &handle_progress/3 - ) + defp maybe_allow_upload(socket, has_buckets) do + cond do + socket.assigns[:uploads] -> + socket + + has_buckets -> + allow_upload(socket, :media_files, + accept: :any, + max_entries: 10, + auto_upload: true, + progress: &handle_progress/3 + ) + + true -> + # No buckets - don't allow upload + socket end end @@ -216,33 +234,33 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do process_upload(socket, path, entry) end) - # Extract the file ID from the result - consume_uploaded_entry returns [{:ok, file_id}] - new_file_id = - case uploaded_results do - [{:ok, file_id}] when is_binary(file_id) -> file_id - _ -> nil - end - - # Reload files to show the newly uploaded file - {files, total_count} = load_files(socket, socket.assigns.current_page) - total_pages = ceil(total_count / socket.assigns.per_page) - - # Auto-select the newly uploaded file - selected_ids = - if new_file_id do - case socket.assigns.mode do - :single -> MapSet.new([new_file_id]) - :multiple -> MapSet.put(socket.assigns.selected_ids, new_file_id) - end - else - socket.assigns.selected_ids - end - - socket - |> assign(:uploaded_files, files) - |> assign(:total_count, total_count) - |> assign(:total_pages, total_pages) - |> assign(:selected_ids, selected_ids) + # Check if upload failed and handle error + case uploaded_results do + [{:ok, file_id}] when is_binary(file_id) -> + # Success - reload files and auto-select + {files, total_count} = load_files(socket, socket.assigns.current_page) + total_pages = ceil(total_count / socket.assigns.per_page) + + selected_ids = + case socket.assigns.mode do + :single -> MapSet.new([file_id]) + :multiple -> MapSet.put(socket.assigns.selected_ids, file_id) + end + + socket + |> assign(:uploaded_files, files) + |> assign(:total_count, total_count) + |> assign(:total_pages, total_pages) + |> assign(:selected_ids, selected_ids) + + _ -> + # Upload failed - show error message + socket + |> put_flash( + :error, + "Upload failed: No storage buckets configured. Please configure at least one storage bucket before uploading files." + ) + end else socket end diff --git a/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex b/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex index f44909140..ca71fd9e1 100644 --- a/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex +++ b/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex @@ -59,70 +59,111 @@ <%!-- Scrollable Content Area --%>
+ <%!-- No Buckets Warning --%> + <%= if !@has_buckets do %> +
+ <.icon name="hero-exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" /> +
+

No Storage Buckets Configured

+
+ You need to configure at least one storage bucket before you can upload files. + <.link + navigate={PhoenixKit.Utils.Routes.path("/admin/settings/media")} + class="link link-primary font-semibold" + > + Configure Storage Buckets + +
+
+
+ <% end %> <%!-- Upload Section --%> -
+

<.icon name="hero-arrow-up-tray" class="w-4 h-4" /> Upload New Files

-
- <%!-- Drag and Drop Zone --%> -
-