diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index 1c9d193b3..4999eda52 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -149,7 +149,7 @@ defmodule PhoenixKit.Migrations.Postgres do - Metadata storage for additional context in audit logs - Performance indexes for efficient querying by user, action, and date - ### V23 - Session Fingerprinting ⚡ LATEST + ### V23 - Session Fingerprinting - Session fingerprinting columns (ip_address, user_agent_hash) in phoenix_kit_users_tokens - Prevents session hijacking by detecting suspicious session usage patterns - IP address tracking: Detects when session is used from different IP @@ -158,18 +158,25 @@ defmodule PhoenixKit.Migrations.Postgres do - Configurable strictness: Can log warnings or force re-authentication - Performance indexes for efficient fingerprint verification + ### V24 - File Checksum Unique Index ⚡ LATEST + - Unique index on phoenix_kit_files.checksum for O(1) duplicate detection + - Enables automatic deduplication of uploaded files + - Prevents redundant storage of identical files + - Improves performance of duplicate file lookups + ## Migration Paths ### Fresh Installation (0 → Current) - Runs all migrations V01 through V23 in sequence. + Runs all migrations V01 through V24 in sequence. ### Incremental Updates - - V01 → V23: Runs V02 through V23 in sequence - - V22 → V23: Runs V23 only (adds session fingerprinting) - - V21 → V23: Runs V22 and V23 in sequence - - V20 → V23: Runs V21, V22, and V23 in sequence + - V01 → V24: Runs V02 through V24 in sequence + - V23 → V24: Runs V24 only (adds file checksum unique index) + - V22 → V24: Runs V23 and V24 in sequence + - V20 → V24: Runs V21, V22, V23, and V24 in sequence ### Rollback Support + - V24 → V23: Removes unique index on checksum - V23 → V22: Removes session fingerprinting columns and indexes - V22 → V21: Removes audit logging system, email orphaned events, and email metrics - V21 → V20: Removes composite message ID index @@ -186,14 +193,14 @@ defmodule PhoenixKit.Migrations.Postgres do ## Usage Examples - # Update to latest version (V23) + # Update to latest version (V24) PhoenixKit.Migrations.Postgres.up(prefix: "myapp") # Update to specific version - PhoenixKit.Migrations.Postgres.up(prefix: "myapp", version: 23) + PhoenixKit.Migrations.Postgres.up(prefix: "myapp", version: 24) # Rollback to specific version - PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 22) + PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 23) # Complete rollback PhoenixKit.Migrations.Postgres.down(prefix: "myapp", version: 0) @@ -211,7 +218,7 @@ defmodule PhoenixKit.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 23 + @current_version 25 @default_prefix "public" @doc false diff --git a/lib/phoenix_kit/migrations/postgres/v24.ex b/lib/phoenix_kit/migrations/postgres/v24.ex new file mode 100644 index 000000000..d0c4309bd --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v24.ex @@ -0,0 +1,69 @@ +defmodule PhoenixKit.Migrations.Postgres.V24 do + @moduledoc """ + PhoenixKit V24 Migration: File Checksum Unique Index + + This migration adds a unique index on the checksum field of the phoenix_kit_files table + to enable efficient duplicate file detection and prevent duplicate file storage. + + ## Changes + + ### File Deduplication Support + - Adds unique index on phoenix_kit_files.checksum for O(1) duplicate lookups + - Enables automatic deduplication of uploaded files + - Prevents redundant storage of identical files + + ## PostgreSQL Support + - Supports PostgreSQL prefix for schema isolation + - Creates unique index for fast duplicate detection + """ + use Ecto.Migration + + @doc """ + Run the V24 file checksum indexing migration. + + Handles existing duplicate checksums by keeping the oldest file and deleting newer duplicates. + """ + def up(%{prefix: prefix} = _opts) do + # First, remove duplicate checksums by keeping the oldest file (first inserted) + # This ensures the migration won't fail due to existing duplicates + remove_duplicate_checksums(prefix) + + # Create unique index on checksum for duplicate detection + create_if_not_exists unique_index(:phoenix_kit_files, [:checksum], prefix: prefix) + + # Set version comment on phoenix_kit table for version tracking + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '24'" + end + + @doc """ + Rollback the V24 file checksum indexing migration. + """ + def down(%{prefix: prefix} = _opts) do + # Drop unique index on checksum + drop_if_exists unique_index(:phoenix_kit_files, [:checksum], prefix: prefix) + + # Update version comment on phoenix_kit table to previous version + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '23'" + end + + # Helper function to remove duplicate checksums + # Keeps the oldest file (earliest inserted_at) and deletes newer duplicates + defp remove_duplicate_checksums(prefix) do + table_name = prefix_table_name("phoenix_kit_files", prefix) + + sql = """ + DELETE FROM #{table_name} f1 + WHERE id NOT IN ( + SELECT DISTINCT ON (checksum) id + FROM #{table_name} + ORDER BY checksum, inserted_at ASC + ) + """ + + execute(sql) + end + + # Helper function to build table name with prefix + 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/migrations/postgres/v25.ex b/lib/phoenix_kit/migrations/postgres/v25.ex new file mode 100644 index 000000000..17fb1c22e --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v25.ex @@ -0,0 +1,54 @@ +defmodule PhoenixKit.Migrations.Postgres.V25 do + @moduledoc """ + PhoenixKit V25 Migration: Aspect Ratio Control for Dimensions + + This migration adds support for aspect ratio preservation in dimension configuration. + Allows users to choose between maintaining aspect ratio (width only) or fixed dimensions. + + ## Changes + + ### Storage Dimensions Table (phoenix_kit_storage_dimensions) + - Adds `maintain_aspect_ratio` boolean column (default: true) + - When true: Only width is used, height is calculated to preserve aspect ratio + - When false: Both width and height are used as fixed dimensions (for thumbnails/crops) + + ## Features + + - **Aspect Ratio Mode**: Responsive sizing with width-only specification + - **Fixed Dimension Mode**: Exact pixel dimensions for square crops/thumbnails + - **Per-Dimension Control**: Each variant can independently choose its mode + - **Default to Aspect Ratio**: All dimensions default to maintaining aspect ratio + """ + use Ecto.Migration + + @doc """ + Run the V25 migration to add aspect ratio control. + """ + def up(%{prefix: prefix} = _opts) do + # Add maintain_aspect_ratio column to dimensions table + alter table(:phoenix_kit_storage_dimensions, prefix: prefix) do + add :maintain_aspect_ratio, :boolean, default: true, null: false + end + + # Set version comment on phoenix_kit table for version tracking + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '25'" + end + + @doc """ + Rollback the V25 migration. + """ + def down(%{prefix: prefix} = _opts) do + # Remove maintain_aspect_ratio column from dimensions table + alter table(:phoenix_kit_storage_dimensions, prefix: prefix) do + remove :maintain_aspect_ratio + end + + # Update version comment on phoenix_kit table to previous version + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '24'" + 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 f4fb83110..4e9360031 100644 --- a/lib/phoenix_kit/storage.ex +++ b/lib/phoenix_kit/storage.ex @@ -17,6 +17,7 @@ defmodule PhoenixKit.Storage do """ import Ecto.Query, warn: false + require Logger alias PhoenixKit.Settings alias PhoenixKit.Storage.Bucket @@ -28,6 +29,7 @@ defmodule PhoenixKit.Storage do # The dedicated storage/media APIs under development should replace this fallback once available. alias PhoenixKit.Storage.URLSigner alias PhoenixKit.Storage.VariantGenerator + alias PhoenixKit.Storage.Workers.ProcessFileJob # ===== BUCKETS ===== @@ -414,7 +416,7 @@ defmodule PhoenixKit.Storage do Gets a file by its hash. """ def get_file_by_hash(hash) do - repo().get_by(PhoenixKit.Storage.File, hash: hash) + repo().get_by(PhoenixKit.Storage.File, checksum: hash) end @doc """ @@ -479,6 +481,20 @@ defmodule PhoenixKit.Storage do repo().get_by(FileInstance, file_id: file_id, variant_name: variant_name) end + @doc """ + Gets the bucket IDs where a file instance is stored. + + Returns a list of bucket IDs from the file_locations for the given file instance. + """ + def get_file_instance_bucket_ids(file_instance_id) do + import Ecto.Query + + FileLocation + |> where([fl], fl.file_instance_id == ^file_instance_id and fl.status == "active") + |> select([fl], fl.bucket_id) + |> repo().all() + end + @doc """ Creates a new file instance. """ @@ -846,6 +862,82 @@ defmodule PhoenixKit.Storage do ext, original_filename \\ nil ) do + # Check if file already exists by hash + case get_file_by_hash(file_hash) do + %PhoenixKit.Storage.File{} = existing_file -> + Logger.info("=== DUPLICATE FILE DETECTED ===") + Logger.info("File ID: #{existing_file.id}, Checksum: #{file_hash}") + Logger.info("File path: #{existing_file.file_path}") + + # File already exists, but check if instances and actual files are healthy + case get_file_instance_by_name(existing_file.id, "original") do + %FileInstance{file_name: stored_file_path} -> + Logger.info("Original instance record found: #{stored_file_path}") + + # Instance record exists, verify actual file exists in storage + case verify_file_in_storage(stored_file_path) do + :exists -> + Logger.info("Duplicate file is healthy in storage. Queueing variant generation.") + # File is healthy in storage, ensure other variants are generated + _ = queue_variant_generation(existing_file, user_id, original_filename) + {:ok, existing_file, :duplicate} + + :missing -> + # File record exists but actual file is missing from storage + # Need to re-store the file and recreate instances + Logger.warning( + "Duplicate file detected but missing from storage: #{existing_file.id}" + ) + + restore_missing_file( + existing_file, + source_path, + file_hash, + user_id, + original_filename + ) + end + + nil -> + # File record exists but instance record is missing + # Need to recreate instances from the stored file + Logger.warning( + "Duplicate file detected but missing instance record: #{existing_file.id}" + ) + + Logger.info("Attempting to recreate instances...") + + recreate_file_instances( + existing_file, + source_path, + file_hash, + user_id, + original_filename + ) + end + + nil -> + Logger.info("New file detected (no existing hash match). Proceeding with storage.") + # File is new, proceed with storage + store_new_file_in_buckets( + source_path, + file_type, + user_id, + file_hash, + ext, + original_filename + ) + end + end + + defp store_new_file_in_buckets( + source_path, + file_type, + user_id, + file_hash, + ext, + original_filename + ) do # Calculate MD5 hash for path structure md5_hash = source_path @@ -885,7 +977,7 @@ defmodule PhoenixKit.Storage do original_path = "#{file_path}/#{md5_hash}_original.#{ext}" case Manager.store_file(source_path, path_prefix: original_path) do - {:ok, _storage_info} -> + {:ok, storage_info} -> # Create file instance for original original_instance_attrs = %{ variant_name: "original", @@ -899,7 +991,16 @@ defmodule PhoenixKit.Storage do } case create_file_instance(original_instance_attrs) do - {:ok, _instance} -> + {:ok, instance} -> + # Create file location records for each bucket where the file was stored + _ = create_file_locations(instance.id, storage_info.bucket_ids, original_path) + + # Queue background job for variant processing + _ = + %{file_id: file.id, user_id: user_id, filename: orig_filename} + |> ProcessFileJob.new() + |> Oban.insert() + {:ok, file} {:error, changeset} -> @@ -921,6 +1022,165 @@ defmodule PhoenixKit.Storage do # ===== HELPER FUNCTIONS ===== + defp queue_variant_generation(file, user_id, original_filename) do + # Queue variant generation to ensure all variants exist for this file + Task.start(fn -> + %{file_id: file.id, user_id: user_id, filename: original_filename} + |> ProcessFileJob.new() + |> Oban.insert() + end) + end + + defp verify_file_in_storage(stored_file_path) do + # Check if file actually exists in storage buckets + Logger.info("Verifying file in storage: #{stored_file_path}") + exists = Manager.file_exists?(stored_file_path) + Logger.info("File exists? #{exists}") + if exists, do: :exists, else: :missing + end + + defp restore_missing_file(existing_file, source_path, file_hash, user_id, original_filename) do + # File record exists but actual file is missing from storage + # Delete broken instances and recreate them (which will also store the file) + + Logger.warning("=== RECOVERING MISSING FILE ===") + Logger.warning("File ID: #{existing_file.id}") + Logger.warning("File path: #{existing_file.file_path}") + Logger.warning("Source path: #{source_path}") + + # First, delete all broken instances for this file + deleted_count = delete_file_instances_for_file(existing_file.id) + Logger.info("Deleted #{deleted_count} broken instances for file: #{existing_file.id}") + + # Recreate instance and store the file (combined in one operation) + Logger.info("Recreating instances for file #{existing_file.id}") + recreate_file_instances(existing_file, source_path, file_hash, user_id, original_filename) + end + + defp delete_file_instances_for_file(file_id) do + # Delete all file instances for a file (to clean up broken ones) + {deleted_count, _} = + from(fi in FileInstance, where: fi.file_id == ^file_id) + |> repo().delete_all() + + Logger.info("Deleted #{deleted_count} file instances for file_id: #{file_id}") + deleted_count + end + + defp recreate_file_instances(file, source_path, file_hash, user_id, original_filename) do + # File record exists but instances are missing or broken + # First store the file in buckets, then recreate the instance record + + Logger.info( + "Starting recreate_file_instances for file: #{file.id}, file_path: #{file.file_path}" + ) + + {:ok, stat} = Elixir.File.stat(source_path) + file_size = stat.size + + # Reconstruct the full storage path for the original instance + # file.file_path is "user_prefix/hash_prefix/md5_hash" + # We need to extract md5_hash and build the original path + [_user_prefix, _hash_prefix, md5_hash | _rest] = String.split(file.file_path, "/") + original_path = "#{file.file_path}/#{md5_hash}_original.#{file.ext}" + + Logger.info("Reconstructed original path for instance: #{original_path}") + + Logger.info( + "About to store file from source_path: #{source_path} to storage path: #{original_path}" + ) + + # First, store the file in buckets using Manager + case Manager.store_file(source_path, path_prefix: original_path) do + {:ok, storage_info} -> + Logger.info( + "File stored in buckets: #{original_path}, bucket_ids: #{inspect(storage_info.bucket_ids)}" + ) + + # Now create the file instance record pointing to the stored file + original_instance_attrs = %{ + variant_name: "original", + file_name: original_path, + mime_type: file.mime_type, + ext: file.ext, + checksum: file_hash, + size: file_size, + processing_status: "completed", + file_id: file.id + } + + case create_file_instance(original_instance_attrs) do + {:ok, _instance} -> + Logger.info( + "Recreated original instance for file: #{file.id}, path: #{original_path}" + ) + + # Delete any remaining broken variant instances BEFORE queuing ProcessFileJob + # This ensures ProcessFileJob creates fresh instances with correct paths + deleted_variants = delete_variant_instances(file.id) + + Logger.info( + "Deleted #{deleted_variants} broken variant instances before regeneration" + ) + + # Queue variant generation for the recovered file + _ = queue_variant_generation(file, user_id, original_filename) + {:ok, file, :duplicate} + + {:error, reason} -> + # Instance creation failed, might be duplicate constraint + # Try deleting old broken instances and recreating + Logger.warning( + "Instance creation failed for file #{file.id}: #{inspect(reason)}, attempting cleanup and retry" + ) + + _ = delete_file_instances_for_file(file.id) + + case create_file_instance(original_instance_attrs) do + {:ok, _instance} -> + Logger.info( + "Recreated original instance for file (after cleanup): #{file.id}, path: #{original_path}" + ) + + # Delete any remaining broken variant instances + deleted_variants = delete_variant_instances(file.id) + + Logger.info( + "Deleted #{deleted_variants} broken variant instances before regeneration" + ) + + _ = queue_variant_generation(file, user_id, original_filename) + {:ok, file, :duplicate} + + {:error, final_reason} -> + Logger.error( + "Failed to recreate instance for file #{file.id}: #{inspect(final_reason)}" + ) + + {:error, final_reason} + end + end + + {:error, store_error} -> + Logger.error( + "Failed to store file in buckets for recreate_file_instances: #{inspect(store_error)}" + ) + + {:error, store_error} + end + end + + defp delete_variant_instances(file_id) do + # Delete only the variant instances (not the original), to clean up broken ones + {deleted_count, _} = + from(fi in FileInstance, + where: fi.file_id == ^file_id and fi.variant_name != "original" + ) + |> repo().delete_all() + + deleted_count + end + defp generate_uuidv7 do UUIDv7.generate() end @@ -1137,4 +1397,20 @@ defmodule PhoenixKit.Storage do random_name = :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower) Path.join(temp_dir, "phoenix_kit_#{random_name}") end + + defp create_file_locations(file_instance_id, bucket_ids, file_path) do + Enum.each(bucket_ids, fn bucket_id -> + location_attrs = %{ + path: file_path, + status: "active", + priority: 0, + file_instance_id: file_instance_id, + bucket_id: bucket_id + } + + repo().insert(%FileLocation{} |> FileLocation.changeset(location_attrs)) + end) + + :ok + end end diff --git a/lib/phoenix_kit/storage/dimension.ex b/lib/phoenix_kit/storage/dimension.ex index 5d521cfc5..96972a1ee 100644 --- a/lib/phoenix_kit/storage/dimension.ex +++ b/lib/phoenix_kit/storage/dimension.ex @@ -83,6 +83,7 @@ defmodule PhoenixKit.Storage.Dimension do field :format, :string field :applies_to, :string field :enabled, :boolean, default: true + field :maintain_aspect_ratio, :boolean, default: true field :order, :integer, default: 0 timestamps(type: :naive_datetime) @@ -95,11 +96,18 @@ defmodule PhoenixKit.Storage.Dimension do - `name` - `applies_to` + - `width` - Required when `maintain_aspect_ratio` is true or false + + ## Optional Fields + + - `height` - Required when `maintain_aspect_ratio` is false (fixed dimensions) + - `maintain_aspect_ratio` - Whether to preserve aspect ratio (default: true) ## Validation Rules - Name must be unique - - At least width or height must be specified + - Width must be specified (always required) + - Height must be specified when `maintain_aspect_ratio` is false - Width and height must be positive integers - Quality must be between 1-100 for images or 0-51 for videos - Applies to must be one of: "image", "video", "both" @@ -116,6 +124,7 @@ defmodule PhoenixKit.Storage.Dimension do :format, :applies_to, :enabled, + :maintain_aspect_ratio, :order ]) |> validate_required([:name, :applies_to]) @@ -133,15 +142,21 @@ defmodule PhoenixKit.Storage.Dimension do |> unique_constraint(:name, name: :phoenix_kit_storage_dimensions_name_index) end - # Validate that at least width or height is specified + # Validate dimensions based on maintain_aspect_ratio setting defp validate_dimension_size(changeset) do width = get_field(changeset, :width) height = get_field(changeset, :height) + maintain_aspect = get_field(changeset, :maintain_aspect_ratio) - if is_nil(width) && is_nil(height) do - add_error(changeset, :width, "at least width or height must be specified") - else - changeset + cond do + is_nil(width) -> + add_error(changeset, :width, "width is required") + + maintain_aspect == false && is_nil(height) -> + add_error(changeset, :height, "height is required when not maintaining aspect ratio") + + true -> + changeset end end @@ -224,18 +239,25 @@ defmodule PhoenixKit.Storage.Dimension do def applies_to_videos?(_), do: false @doc """ - Returns whether this dimension preserves aspect ratio (only one dimension specified). + Returns whether this dimension preserves aspect ratio. """ - def preserve_aspect_ratio?(%__MODULE__{width: width, height: height}) do - is_nil(width) || is_nil(height) + def preserve_aspect_ratio?(%__MODULE__{maintain_aspect_ratio: maintain_aspect}) do + maintain_aspect == true end @doc """ Returns a human-readable description of this dimension. """ - def description(%__MODULE__{name: name, width: width, height: height, format: format}) do + def description(%__MODULE__{ + name: name, + width: width, + height: height, + format: format, + maintain_aspect_ratio: maintain_aspect + }) do size_desc = cond do + maintain_aspect && width -> "#{width}px wide (aspect ratio maintained)" width && height -> "#{width}×#{height}" width -> "#{width}px wide" height -> "#{height}px tall" diff --git a/lib/phoenix_kit/storage/manager.ex b/lib/phoenix_kit/storage/manager.ex index db97fc6e6..8a571f42a 100644 --- a/lib/phoenix_kit/storage/manager.ex +++ b/lib/phoenix_kit/storage/manager.ex @@ -18,6 +18,7 @@ defmodule PhoenixKit.Storage.Manager do - `:redundancy_copies` - Number of copies to store (default: from settings) - `:priority_buckets` - List of specific bucket IDs to use (default: auto-select) + - `:force_bucket_ids` - List of specific bucket IDs to use (overrides priority_buckets) - `:generate_variants` - Whether to generate variants (default: from settings) ## Returns @@ -28,11 +29,16 @@ defmodule PhoenixKit.Storage.Manager do def store_file(source_path, opts \\ []) do # Get redundancy settings redundancy_copies = Keyword.get(opts, :redundancy_copies, get_redundancy_copies()) + force_bucket_ids = Keyword.get(opts, :force_bucket_ids, []) priority_buckets = Keyword.get(opts, :priority_buckets, []) _generate_variants = Keyword.get(opts, :generate_variants, get_auto_generate_variants()) + # Use force_bucket_ids if provided, otherwise use priority_buckets + buckets_to_use = + if Enum.empty?(force_bucket_ids), do: priority_buckets, else: force_bucket_ids + # Select buckets for storage - buckets = select_buckets_for_storage(redundancy_copies, priority_buckets) + buckets = select_buckets_for_storage(redundancy_copies, buckets_to_use) if Enum.empty?(buckets) do {:error, "No available storage buckets"} @@ -113,13 +119,24 @@ defmodule PhoenixKit.Storage.Manager do defp select_buckets_for_storage(redundancy_copies, priority_buckets) do if Enum.empty?(priority_buckets) do - # Auto-select buckets based on priority and available space - get_enabled_buckets() - |> Enum.sort_by(&bucket_priority/1) + # Get fresh bucket list from database (don't use cache for selection) + # This ensures we get the current state and can shuffle properly + all_buckets = PhoenixKit.Storage.list_enabled_buckets() + + # Separate buckets by priority + {auto_priority_buckets, fixed_priority_buckets} = + Enum.split_with(all_buckets, &(&1.priority == 0)) + + # Shuffle auto-priority buckets (priority = 0) for random distribution + # Fixed priority buckets are deterministic + shuffled_auto = Enum.shuffle(auto_priority_buckets) + + # Combine: fixed priority buckets first (sorted), then shuffled auto-priority + (Enum.sort_by(fixed_priority_buckets, & &1.priority) ++ shuffled_auto) |> Enum.take(redundancy_copies) else # Use specified buckets - get_enabled_buckets() + PhoenixKit.Storage.list_enabled_buckets() |> Enum.filter(&(&1.id in priority_buckets)) |> Enum.take(redundancy_copies) end @@ -189,20 +206,6 @@ defmodule PhoenixKit.Storage.Manager do provider_module end - defp bucket_priority(bucket) do - if bucket.priority == 0 do - # Random priority - use available space as tiebreaker - used_space = PhoenixKit.Storage.calculate_bucket_usage(bucket.id) - # Large default - max_space = bucket.max_size_mb || 1_000_000 - free_space_ratio = (max_space - used_space) / max_space - # Negative for descending sort - {0, -free_space_ratio} - else - {bucket.priority, 0} - end - end - defp generate_destination_path(source_path, opts) do original_name = Path.basename(source_path) extension = Path.extname(original_name) diff --git a/lib/phoenix_kit/storage/variant_generator.ex b/lib/phoenix_kit/storage/variant_generator.ex index 279fc044e..db9f90beb 100644 --- a/lib/phoenix_kit/storage/variant_generator.ex +++ b/lib/phoenix_kit/storage/variant_generator.ex @@ -116,7 +116,7 @@ defmodule PhoenixKit.Storage.VariantGenerator do process_variant(original_path, variant_path, file.mime_type, dimension), {:ok, file_stats} <- get_variant_file_stats(variant_path), {:ok, _storage_info} <- - store_variant_file(variant_path, variant_name, variant_storage_path), + store_variant_file(variant_path, variant_name, variant_storage_path, file.id), {:ok, instance} <- create_variant_instance( file, @@ -147,10 +147,37 @@ defmodule PhoenixKit.Storage.VariantGenerator do end end - defp store_variant_file(variant_path, variant_name, storage_path) do + defp store_variant_file(variant_path, variant_name, storage_path, file_id) do Logger.info("Storing variant #{variant_name} to storage buckets at path: #{storage_path}") - case Manager.store_file(variant_path, generate_variants: false, path_prefix: storage_path) do + # Get the bucket IDs from the original file instance if available + opts = + case file_id do + nil -> + [generate_variants: false, path_prefix: storage_path] + + file_id -> + # Get the original instance's bucket IDs + case Storage.get_file_instance_by_name(file_id, "original") do + %Storage.FileInstance{id: original_instance_id} -> + bucket_ids = Storage.get_file_instance_bucket_ids(original_instance_id) + + if Enum.empty?(bucket_ids) do + [generate_variants: false, path_prefix: storage_path] + else + [ + generate_variants: false, + path_prefix: storage_path, + force_bucket_ids: bucket_ids + ] + end + + nil -> + [generate_variants: false, path_prefix: storage_path] + end + end + + case Manager.store_file(variant_path, opts) do {:ok, _storage_info} = success -> Logger.info("Variant #{variant_name} stored successfully in buckets") success @@ -161,20 +188,29 @@ defmodule PhoenixKit.Storage.VariantGenerator do end defp create_variant_instance(file, variant_name, storage_path, mime_type, ext, stats) do - instance_attrs = %{ - variant_name: variant_name, - file_name: storage_path, - mime_type: mime_type, - ext: ext, - checksum: stats.checksum, - size: stats.size, - width: stats.width, - height: stats.height, - processing_status: "completed", - file_id: file.id - } - - Storage.create_file_instance(instance_attrs) + # Check if variant already exists + case Storage.get_file_instance_by_name(file.id, variant_name) do + %Storage.FileInstance{} = existing_instance -> + # Variant already exists, return it + {:ok, existing_instance} + + nil -> + # Create new variant instance + instance_attrs = %{ + variant_name: variant_name, + file_name: storage_path, + mime_type: mime_type, + ext: ext, + checksum: stats.checksum, + size: stats.size, + width: stats.width, + height: stats.height, + processing_status: "completed", + file_id: file.id + } + + Storage.create_file_instance(instance_attrs) + end end defp cleanup_temp_files(paths) do @@ -275,30 +311,37 @@ defmodule PhoenixKit.Storage.VariantGenerator do defp process_image_variant(input_path, output_path, _mime_type, dimension) do Logger.info( - "process_image_variant: input=#{input_path} output=#{output_path} width=#{dimension.width} height=#{dimension.height}" + "process_image_variant: input=#{input_path} output=#{output_path} width=#{dimension.width} height=#{dimension.height} maintain_aspect=#{dimension.maintain_aspect_ratio}" ) quality = dimension.quality || 85 format = dimension.format - # Use center-crop for dimensions with both width and height (e.g., thumbnails) - # Use regular resize for dimensions with only one specified (maintains aspect ratio) - case {dimension.width, dimension.height} do - {w, h} when w != nil and h != nil -> - # Both dimensions specified - use center-crop with gravity - Logger.info("Using center-crop for #{dimension.name} (#{w}x#{h})") + # Decision based on maintain_aspect_ratio setting + case dimension.maintain_aspect_ratio do + true -> + # Maintain aspect ratio - use only width + Logger.info("Using responsive resize for #{dimension.name} (width: #{dimension.width}px)") - ImageProcessor.resize_and_crop_center(input_path, output_path, w, h, + ImageProcessor.resize(input_path, output_path, dimension.width, nil, quality: quality, - format: format, - background: "white" + format: format + ) + + false -> + # Fixed dimensions - use center-crop with gravity + Logger.info( + "Using center-crop for #{dimension.name} (#{dimension.width}x#{dimension.height})" ) - _ -> - # Only one dimension specified - use regular resize to maintain aspect ratio - ImageProcessor.resize(input_path, output_path, dimension.width, dimension.height, + ImageProcessor.resize_and_crop_center( + input_path, + output_path, + dimension.width, + dimension.height, quality: quality, - format: format + format: format, + background: "white" ) end end diff --git a/lib/phoenix_kit/users/auth.ex b/lib/phoenix_kit/users/auth.ex index 714b38077..c1b8fad46 100644 --- a/lib/phoenix_kit/users/auth.ex +++ b/lib/phoenix_kit/users/auth.ex @@ -1132,6 +1132,89 @@ defmodule PhoenixKit.Users.Auth do update_user_custom_fields(user, merged_custom_fields) end + @doc """ + Update a user's avatar by storing the file and saving the file ID. + + This function handles the complete avatar upload workflow: + 1. Stores the file in configured storage buckets + 2. Automatically queues background job for variant generation + 3. Saves the file ID to the user's custom_fields + + This is a convenience function that combines file storage with user update. + Can be called from any context (LiveView, controllers, scripts, etc.) outside + of the PhoenixKit project. + + ## Parameters + - `user` - The User struct to update + - `file_path` - Path to the uploaded file (temporary location) + - `filename` - Original filename for the upload + - `user_id` - The user ID owning this file (defaults to user.id) + + ## Returns + - `{:ok, user}` - Avatar saved successfully + - `{:error, reason}` - File storage or update failed + + ## Examples + + # Store avatar in default location with automatic variant generation + {:ok, updated_user} = Auth.update_user_avatar(user, "/tmp/upload_xyz", "avatar.jpg") + + # Store with explicit user_id (for custom workflows) + {:ok, updated_user} = Auth.update_user_avatar(user, "/tmp/upload_xyz", "avatar.jpg", custom_user_id) + + ## Automatically Generated Variants + The storage layer automatically generates these image variants: + - original - Full-size image + - large - 800x800px + - medium - 400x400px + - small - 200x200px + - thumbnail - 100x100px + """ + def update_user_avatar(%User{} = user, file_path, filename, user_id \\ nil) do + user_id = user_id || user.id + + # Calculate file hash + file_hash = calculate_file_hash(file_path) + + # Get file extension + ext = Path.extname(filename) |> String.replace_leading(".", "") + + # Store file in buckets (automatically queues ProcessFileJob for variants) + case PhoenixKit.Storage.store_file_in_buckets( + file_path, + "image", + user_id, + file_hash, + ext, + filename + ) do + {:ok, file} -> + # Save the file ID to user's custom fields + update_user_fields(user, %{"avatar_file_id" => file.id}) + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Calculate SHA256 hash of a file. + + Used internally for file integrity verification. + + ## Parameters + - `file_path` - Path to the file + + ## Returns + - String containing the lowercase hexadecimal SHA256 hash + """ + def calculate_file_hash(file_path) do + file_path + |> File.read!() + |> then(fn data -> :crypto.hash(:sha256, data) end) + |> Base.encode16(case: :lower) + end + @doc """ Bulk update multiple users with the same field values. diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index f68ec1662..a55a5c970 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -10,6 +10,7 @@ defmodule PhoenixKitWeb.Components.AdminNav do alias PhoenixKit.Module.Languages alias PhoenixKit.Settings alias PhoenixKit.ThemeConfig + alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.Routes import PhoenixKitWeb.Components.Core.Icon @@ -270,6 +271,14 @@ defmodule PhoenixKitWeb.Components.AdminNav do attr(:current_locale, :string, default: "en") def admin_user_dropdown(assigns) do + user = Scope.user(assigns.scope) + avatar_file_id = user && user.custom_fields && user.custom_fields["avatar_file_id"] + + assigns = + assigns + |> assign(:avatar_file_id, avatar_file_id) + |> assign(:user, user) + ~H""" <%= if @scope && PhoenixKit.Users.Auth.Scope.authenticated?(@scope) do %>
- <%= if @accept_description do %> - Supported formats: {@accept_description} + <%!-- File Type and Size Info --%> + <%= if @accept_description != nil or @max_size_description != nil do %> +
<%= if @max_size_description do %>
-
+ Maximum file size: {@max_size_description}
<% end %>
- <% end %>
- <%= if @max_size_description do %>
- Maximum file size: {@max_size_description}
- <% end %>
-
{entry.client_name}
+ ++ <%= if get_field_value(@changeset, :maintain_aspect_ratio) != false do %> + When enabled: Only width is used, height is calculated automatically + <% else %> + When disabled: Both width and height are used for fixed dimensions (crops/thumbnails) + <% end %> +
+
- Size Settings:
- Width and height define the target dimensions Manage user media and uploads
- Upload images, videos, or PDFs. Files are automatically processed and optimized.
-
- Aspect Ratio:
- Leave one dimension empty to maintain aspect ratio
+ Maintain Aspect Ratio (Default ON):
+ When enabled, only the width is used and height is calculated automatically to preserve the image's aspect ratio. Perfect for responsive images.
+ Fixed Dimensions (When Aspect Ratio OFF):
+ Both width and height are used for exact dimensions. Images are center-cropped to fit. Great for thumbnails and square previews.
Quality Settings:
Images use 1-100 (compression quality), Videos use CRF 0-51 (constant rate factor)
Format Override:
diff --git a/lib/phoenix_kit_web/live/settings/storage/dimensions.html.heex b/lib/phoenix_kit_web/live/settings/storage/dimensions.html.heex
index 349e09728..97de134b0 100644
--- a/lib/phoenix_kit_web/live/settings/storage/dimensions.html.heex
+++ b/lib/phoenix_kit_web/live/settings/storage/dimensions.html.heex
@@ -101,7 +101,14 @@
Name
- Size
+
+ <%= if Enum.any?(Enum.filter(@dimensions, & &1.applies_to in ["image", "both"]), & &1.maintain_aspect_ratio) do %>
+ Target Width / Size
+ <% else %>
+ Size
+ <% end %>
+
+ Mode
Quality
Format
Status
@@ -116,9 +123,20 @@
- {format_dimension_size(dimension.width, dimension.height)}
+ <%= if dimension.maintain_aspect_ratio do %>
+ {dimension.width}px
+ <% else %>
+ {format_dimension_size(dimension.width, dimension.height)}
+ <% end %>
+
+ <%= if dimension.maintain_aspect_ratio do %>
+ Aspect Ratio
+ <% else %>
+ Fixed
+ <% end %>
+
{dimension.quality}%
@@ -205,7 +223,14 @@
Name
- Resolution
+
+ <%= if Enum.any?(Enum.filter(@dimensions, & &1.applies_to in ["video", "both"]), & &1.maintain_aspect_ratio) do %>
+ Target Width / Resolution
+ <% else %>
+ Resolution
+ <% end %>
+
+ Mode
Quality (CRF)
Codec
Status
@@ -220,9 +245,20 @@
- {format_dimension_size(dimension.width, dimension.height)}
+ <%= if dimension.maintain_aspect_ratio do %>
+ {dimension.width}px
+ <% else %>
+ {format_dimension_size(dimension.width, dimension.height)}
+ <% end %>
+
+ <%= if dimension.maintain_aspect_ratio do %>
+ Aspect Ratio
+ <% else %>
+ Fixed
+ <% end %>
+
{dimension.quality}
diff --git a/lib/phoenix_kit_web/live/users/media.ex b/lib/phoenix_kit_web/live/users/media.ex
index 5d064faa8..5ce38d003 100644
--- a/lib/phoenix_kit_web/live/users/media.ex
+++ b/lib/phoenix_kit_web/live/users/media.ex
@@ -12,7 +12,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do
alias PhoenixKit.Settings
alias PhoenixKit.Storage.FileInstance
alias PhoenixKit.Storage.URLSigner
- alias PhoenixKit.Storage.Workers.ProcessFileJob
+ alias PhoenixKit.Users.Auth
alias PhoenixKit.Utils.Routes
def mount(params, _session, socket) do
@@ -40,6 +40,8 @@ defmodule PhoenixKitWeb.Live.Users.Media do
|> assign(:project_title, settings["project_title"])
|> assign(:current_locale, locale)
|> assign(:url_path, Routes.path("/admin/users/media"))
+ |> assign(:show_upload, false)
+ |> assign(:last_uploaded_file_ids, [])
{:ok, socket}
end
@@ -64,6 +66,10 @@ defmodule PhoenixKitWeb.Live.Users.Media do
{:noreply, socket}
end
+ def handle_event("toggle_upload", _params, socket) do
+ {:noreply, assign(socket, :show_upload, !socket.assigns.show_upload)}
+ end
+
def handle_event("validate", _params, socket) do
# File selection event - files will auto-upload
entries = socket.assigns.uploads.media_files.entries
@@ -81,6 +87,13 @@ defmodule PhoenixKitWeb.Live.Users.Media do
{:noreply, cancel_upload(socket, :media_files, ref)}
end
+ def handle_info({:file_uploaded, file_id}, socket) do
+ # This event can be used by other modules listening to uploaded files
+ # For example, avatar upload systems can listen for this event
+ Logger.info("File uploaded with ID: #{file_id}")
+ {:noreply, socket}
+ end
+
def handle_info(:check_uploads_complete, socket) do
entries = socket.assigns.uploads.media_files.entries
@@ -105,56 +118,7 @@ defmodule PhoenixKitWeb.Live.Users.Media do
# Process uploaded files
uploaded_files =
consume_uploaded_entries(socket, :media_files, fn %{path: path}, entry ->
- # Get file info
- ext = Path.extname(entry.client_name) |> String.replace_leading(".", "")
- mime_type = entry.client_type || MIME.from_path(entry.client_name)
- file_type = determine_file_type(mime_type)
-
- # Get current user
- current_user = socket.assigns.phoenix_kit_current_user
- user_id = if current_user, do: current_user.id, else: 1
-
- # Get file size
- {:ok, stat} = Elixir.File.stat(path)
- file_size = stat.size
-
- # Calculate hash
- file_hash = calculate_file_hash(path)
-
- # Store file in storage
- case PhoenixKit.Storage.store_file_in_buckets(
- path,
- file_type,
- user_id,
- file_hash,
- ext,
- entry.client_name
- ) do
- {:ok, file} ->
- # Queue background job for processing
- _job =
- %{file_id: file.id, user_id: user_id, filename: entry.client_name}
- |> ProcessFileJob.new()
- |> Oban.insert()
-
- # Generate URLs for available variants (start with original)
- urls = generate_file_urls(file.id)
-
- {:ok,
- %{
- file_id: file.id,
- filename: entry.client_name,
- file_type: file_type,
- mime_type: mime_type,
- size: file_size,
- status: file.status,
- urls: urls
- }}
-
- {:error, reason} ->
- Logger.error("Storage Error: #{inspect(reason)}")
- {:error, reason}
- end
+ process_single_upload(socket, path, entry)
end)
# Reload paginated data from database to show newly uploaded files
@@ -163,16 +127,26 @@ defmodule PhoenixKitWeb.Live.Users.Media do
{refreshed_files, total_count} = load_existing_files(page, per_page)
total_pages = ceil(total_count / per_page)
+ # Extract file IDs for callbacks
+ file_ids = Enum.map(uploaded_files, &get_file_id/1)
+
+ # Build flash message based on upload results
+ flash_message = build_upload_flash_message(uploaded_files)
+
socket =
socket
|> assign(:uploaded_files, refreshed_files)
|> assign(:total_count, total_count)
|> assign(:total_pages, total_pages)
- |> put_flash(:info, "Upload successful! #{length(uploaded_files)} file(s) processed")
+ |> assign(:last_uploaded_file_ids, file_ids)
+ |> put_flash(:info, flash_message)
{:noreply, socket}
end
+ defp get_file_id({:ok, %{file_id: file_id}}), do: file_id
+ defp get_file_id(_), do: nil
+
# Generate URLs from pre-loaded instances (no database query needed)
defp generate_urls_from_instances(instances, file_id) do
Enum.reduce(instances, %{}, fn instance, acc ->
@@ -181,27 +155,6 @@ defmodule PhoenixKitWeb.Live.Users.Media do
end)
end
- # Legacy function for cases where we need to query a single file's instances
- defp generate_file_urls(file_id) do
- import Ecto.Query
-
- repo = Application.get_env(:phoenix_kit, :repo)
-
- instances =
- FileInstance
- |> where([fi], fi.file_id == ^file_id)
- |> repo.all()
-
- generate_urls_from_instances(instances, file_id)
- end
-
- defp calculate_file_hash(file_path) do
- file_path
- |> Elixir.File.read!()
- |> then(fn data -> :crypto.hash(:sha256, data) end)
- |> Base.encode16(case: :lower)
- end
-
defp determine_file_type(mime_type) do
cond do
String.starts_with?(mime_type, "image/") -> "image"
@@ -284,4 +237,81 @@ defmodule PhoenixKitWeb.Live.Users.Media do
{existing_files, total_count}
end
+
+ defp process_single_upload(socket, path, entry) do
+ # Get file info
+ ext = Path.extname(entry.client_name) |> String.replace_leading(".", "")
+ mime_type = entry.client_type || MIME.from_path(entry.client_name)
+ file_type = determine_file_type(mime_type)
+
+ # Get current user
+ current_user = socket.assigns.phoenix_kit_current_user
+ user_id = if current_user, do: current_user.id, else: 1
+
+ # Get file size
+ {:ok, stat} = Elixir.File.stat(path)
+ file_size = stat.size
+
+ # Calculate hash
+ file_hash = Auth.calculate_file_hash(path)
+
+ # Store file in storage
+ case PhoenixKit.Storage.store_file_in_buckets(
+ path,
+ file_type,
+ user_id,
+ file_hash,
+ ext,
+ entry.client_name
+ ) do
+ {:ok, file, :duplicate} ->
+ build_upload_result(file, entry, file_type, mime_type, file_size, true)
+
+ {:ok, file} ->
+ build_upload_result(file, entry, file_type, mime_type, file_size, false)
+
+ {:error, reason} ->
+ Logger.error("Storage Error: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ defp build_upload_result(file, entry, file_type, mime_type, file_size, is_duplicate) do
+ result = %{
+ file_id: file.id,
+ filename: entry.client_name,
+ file_type: file_type,
+ mime_type: mime_type,
+ size: file_size,
+ status: file.status,
+ urls: %{}
+ }
+
+ result = if is_duplicate, do: Map.put(result, :duplicate, true), else: result
+ {:ok, result}
+ end
+
+ defp build_upload_flash_message(uploaded_files) do
+ duplicate_count =
+ Enum.count(uploaded_files, fn
+ %{duplicate: true} -> true
+ _ -> false
+ end)
+
+ new_count = length(uploaded_files) - duplicate_count
+
+ case {new_count, duplicate_count} do
+ {0, n} when n > 0 ->
+ "Already have #{n} duplicate file(s). No new files were added."
+
+ {n, 0} when n > 0 ->
+ "Upload successful! #{n} new file(s) processed"
+
+ {n, d} when n > 0 and d > 0 ->
+ "Upload successful! #{n} new file(s) added. #{d} file(s) were already uploaded."
+
+ _ ->
+ "Upload processed"
+ end
+ end
end
diff --git a/lib/phoenix_kit_web/live/users/media.html.heex b/lib/phoenix_kit_web/live/users/media.html.heex
index f4365f819..d95183c37 100644
--- a/lib/phoenix_kit_web/live/users/media.html.heex
+++ b/lib/phoenix_kit_web/live/users/media.html.heex
@@ -12,180 +12,44 @@
Upload Media
-