diff --git a/lib/components_elixir/datasheet_downloader.ex b/lib/components_elixir/datasheet_downloader.ex new file mode 100644 index 0000000..e8368d6 --- /dev/null +++ b/lib/components_elixir/datasheet_downloader.ex @@ -0,0 +1,137 @@ +defmodule ComponentsElixir.DatasheetDownloader do + @moduledoc """ + Module for downloading datasheet PDFs from URLs. + """ + + require Logger + + @doc """ + Downloads a PDF from the given URL and saves it to the datasheets folder. + Returns {:ok, filename} on success or {:error, reason} on failure. + """ + def download_pdf_from_url(url) when is_binary(url) do + with {:ok, %URI{scheme: scheme}} when scheme in ["http", "https"] <- validate_url(url), + {:ok, filename} <- generate_filename(url), + {:ok, response} <- fetch_pdf(url), + :ok <- validate_pdf_content(response.body), + :ok <- save_file(filename, response.body) do + {:ok, filename} + else + {:error, reason} -> {:error, reason} + error -> {:error, "Unexpected error: #{inspect(error)}"} + end + end + + def download_pdf_from_url(_), do: {:error, "Invalid URL"} + + defp validate_url(url) do + case URI.parse(url) do + %URI{scheme: scheme} when scheme in ["http", "https"] -> + {:ok, URI.parse(url)} + _ -> + {:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."} + end + end + + defp generate_filename(url) do + # Try to extract a meaningful filename from the URL + uri = URI.parse(url) + original_filename = + case Path.basename(uri.path || "") do + "" -> "datasheet" + basename -> + # Remove extension and sanitize + basename + |> Path.rootname() + |> sanitize_filename() + end + + # Create a unique filename with timestamp + timestamp = System.unique_integer([:positive]) + filename = "#{timestamp}_#{original_filename}.pdf" + {:ok, filename} + end + + defp sanitize_filename(filename) do + filename + |> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores + |> String.replace(~r/_+/, "_") # Replace multiple underscores with single + |> String.trim("_") # Remove leading/trailing underscores + |> String.slice(0, 50) # Limit length + |> case do + "" -> "datasheet" + name -> name + end + end + + defp fetch_pdf(url) do + case Req.get(url, + redirect: true, + max_redirects: 5, + receive_timeout: 30_000, + headers: [ + {"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"} + ] + ) do + {:ok, %Req.Response{status: 200} = response} -> + {:ok, response} + {:ok, %Req.Response{status: status}} -> + {:error, "HTTP error: #{status}"} + {:error, reason} -> + Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}") + {:error, "Download failed: #{inspect(reason)}"} + end + end + + defp validate_pdf_content(body) do + # Check if the response body looks like a PDF (starts with %PDF) + case body do + <<"%PDF", _rest::binary>> -> + :ok + _ -> + {:error, "Downloaded content is not a valid PDF file"} + end + end + + defp save_file(filename, content) do + uploads_dir = Application.get_env(:components_elixir, :uploads_dir) + datasheets_dir = Path.join([uploads_dir, "datasheets"]) + file_path = Path.join(datasheets_dir, filename) + + # Ensure the datasheets directory exists + case File.mkdir_p(datasheets_dir) do + :ok -> + case File.write(file_path, content) do + :ok -> + Logger.info("Successfully saved datasheet: #{filename}") + :ok + {:error, reason} -> + Logger.error("Failed to save datasheet file: #{inspect(reason)}") + {:error, "Failed to save file: #{inspect(reason)}"} + end + {:error, reason} -> + Logger.error("Failed to create datasheets directory: #{inspect(reason)}") + {:error, "Failed to create directory: #{inspect(reason)}"} + end + end + + @doc """ + Deletes a datasheet file from the filesystem. + """ + def delete_datasheet_file(nil), do: :ok + def delete_datasheet_file(""), do: :ok + + def delete_datasheet_file(filename) do + uploads_dir = Application.get_env(:components_elixir, :uploads_dir) + file_path = Path.join([uploads_dir, "datasheets", filename]) + + case File.rm(file_path) do + :ok -> + Logger.info("Deleted datasheet file: #{filename}") + :ok + {:error, reason} -> + Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}") + {:error, reason} + end + end +end diff --git a/lib/components_elixir/inventory.ex b/lib/components_elixir/inventory.ex index c42f115..6d7ad25 100644 --- a/lib/components_elixir/inventory.ex +++ b/lib/components_elixir/inventory.ex @@ -320,6 +320,30 @@ defmodule ComponentsElixir.Inventory do |> Repo.insert() end + @doc """ + Creates a component and downloads datasheet from URL if provided. + """ + def create_component_with_datasheet(attrs \\ %{}) do + # If a datasheet_url is provided, download it + updated_attrs = + case Map.get(attrs, "datasheet_url") do + url when is_binary(url) and url != "" -> + case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do + {:ok, filename} -> + Map.put(attrs, "datasheet_filename", filename) + {:error, _reason} -> + # Continue without datasheet file if download fails + attrs + end + _ -> + attrs + end + + %Component{} + |> Component.changeset(updated_attrs) + |> Repo.insert() + end + @doc """ Updates a component. """ @@ -329,6 +353,34 @@ defmodule ComponentsElixir.Inventory do |> Repo.update() end + @doc """ + Updates a component and downloads datasheet from URL if provided. + """ + def update_component_with_datasheet(%Component{} = component, attrs) do + # If a datasheet_url is provided and changed, download it + updated_attrs = + case Map.get(attrs, "datasheet_url") do + url when is_binary(url) and url != "" and url != component.datasheet_url -> + case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do + {:ok, filename} -> + # Delete old datasheet file if it exists + if component.datasheet_filename do + ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename) + end + Map.put(attrs, "datasheet_filename", filename) + {:error, _reason} -> + # Keep existing filename if download fails + attrs + end + _ -> + attrs + end + + component + |> Component.changeset(updated_attrs) + |> Repo.update() + end + @doc """ Deletes a component. """ diff --git a/lib/components_elixir/inventory/component.ex b/lib/components_elixir/inventory/component.ex index bc9280b..d7438b8 100644 --- a/lib/components_elixir/inventory/component.ex +++ b/lib/components_elixir/inventory/component.ex @@ -18,6 +18,7 @@ defmodule ComponentsElixir.Inventory.Component do field :legacy_position, :string field :count, :integer, default: 0 field :datasheet_url, :string + field :datasheet_filename, :string field :image_filename, :string belongs_to :category, Category @@ -29,7 +30,7 @@ defmodule ComponentsElixir.Inventory.Component do @doc false def changeset(component, attrs) do component - |> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id]) + |> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :datasheet_filename, :image_filename, :category_id, :storage_location_id]) |> validate_required([:name, :category_id]) |> validate_length(:name, min: 1, max: 255) |> validate_length(:description, max: 2000) @@ -59,6 +60,14 @@ defmodule ComponentsElixir.Inventory.Component do |> cast(attrs, [:image_filename]) end + @doc """ + Changeset for updating component datasheet. + """ + def datasheet_changeset(component, attrs) do + component + |> cast(attrs, [:datasheet_filename]) + end + defp validate_url(changeset, field) do validate_change(changeset, field, fn ^field, url -> if url && url != "" do @@ -78,6 +87,12 @@ defmodule ComponentsElixir.Inventory.Component do def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true def has_image?(_), do: false + @doc """ + Returns true if the component has a datasheet file. + """ + def has_datasheet?(%__MODULE__{datasheet_filename: filename}) when is_binary(filename), do: true + def has_datasheet?(_), do: false + @doc """ Returns the search text for this component. """ diff --git a/lib/components_elixir_web/controllers/file_controller.ex b/lib/components_elixir_web/controllers/file_controller.ex index 450bfe5..a2fa0b0 100644 --- a/lib/components_elixir_web/controllers/file_controller.ex +++ b/lib/components_elixir_web/controllers/file_controller.ex @@ -1,29 +1,79 @@ defmodule ComponentsElixirWeb.FileController do use ComponentsElixirWeb, :controller - def show(conn, %{"filename" => filename}) do - # Security: only allow alphanumeric, dashes, underscores, and dots - if String.match?(filename, ~r/^[a-zA-Z0-9_\-\.]+$/) do - uploads_dir = Application.get_env(:components_elixir, :uploads_dir) - file_path = Path.join([uploads_dir, "images", filename]) + def show(conn, %{"filename" => encoded_filename}) do + case decode_and_validate_filename(encoded_filename) do + {:ok, filename} -> + uploads_dir = Application.get_env(:components_elixir, :uploads_dir) + file_path = Path.join([uploads_dir, "images", filename]) - if File.exists?(file_path) do - # Get the file's MIME type - mime_type = get_mime_type(filename) + if File.exists?(file_path) do + # Get the file's MIME type + mime_type = get_mime_type(filename) + conn + |> put_resp_content_type(mime_type) + |> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day + |> send_file(200, file_path) + else + conn + |> put_status(:not_found) + |> text("File not found") + end + + {:error, reason} -> conn - |> put_resp_content_type(mime_type) - |> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day - |> send_file(200, file_path) + |> put_status(:bad_request) + |> text(reason) + end + end + + def show_datasheet(conn, %{"filename" => encoded_filename}) do + case decode_and_validate_filename(encoded_filename) do + {:ok, filename} -> + uploads_dir = Application.get_env(:components_elixir, :uploads_dir) + file_path = Path.join([uploads_dir, "datasheets", filename]) + + if File.exists?(file_path) do + # Get the file's MIME type + mime_type = get_mime_type(filename) + + conn + |> put_resp_content_type(mime_type) + |> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day + |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"") + |> send_file(200, file_path) + else + conn + |> put_status(:not_found) + |> text("File not found") + end + + {:error, reason} -> + conn + |> put_status(:bad_request) + |> text(reason) + end + end + + defp decode_and_validate_filename(encoded_filename) do + try do + # URL decode the filename + decoded_filename = URI.decode(encoded_filename) + + # Security validation: prevent directory traversal and only allow safe characters + # Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation + if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and + not String.contains?(decoded_filename, "..") and + not String.starts_with?(decoded_filename, "/") and + not String.contains?(decoded_filename, "\\") do + {:ok, decoded_filename} else - conn - |> put_status(:not_found) - |> text("File not found") + {:error, "Invalid filename: contains unsafe characters"} end - else - conn - |> put_status(:bad_request) - |> text("Invalid filename") + rescue + _ -> + {:error, "Invalid filename: cannot decode"} end end @@ -34,6 +84,7 @@ defmodule ComponentsElixirWeb.FileController do ".png" -> "image/png" ".gif" -> "image/gif" ".webp" -> "image/webp" + ".pdf" -> "application/pdf" _ -> "application/octet-stream" end end diff --git a/lib/components_elixir_web/live/components_live.ex b/lib/components_elixir_web/live/components_live.ex index 2f4a623..1c7bec2 100644 --- a/lib/components_elixir_web/live/components_live.ex +++ b/lib/components_elixir_web/live/components_live.ex @@ -47,6 +47,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do max_entries: 1, max_file_size: 5_000_000 ) + |> allow_upload(:datasheet, + accept: ~w(.pdf), + max_entries: 1, + max_file_size: 10_000_000 + ) |> load_components()} end end @@ -350,11 +355,18 @@ defmodule ComponentsElixirWeb.ComponentsLive do {:noreply, cancel_upload(socket, :image, ref)} end - def handle_event("save_component", %{"component" => component_params}, socket) do - # Handle any uploaded images - updated_params = save_uploaded_image(socket, component_params) + def handle_event("cancel-datasheet-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :datasheet, ref)} + end - case Inventory.create_component(updated_params) do + def handle_event("save_component", %{"component" => component_params}, socket) do + # Handle any uploaded files + updated_params = + socket + |> save_uploaded_image(component_params) + |> save_uploaded_datasheet(socket) + + case Inventory.create_component_with_datasheet(updated_params) do {:ok, _component} -> {:noreply, socket @@ -369,10 +381,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do end def handle_event("save_edit", %{"component" => component_params}, socket) do - # Handle any uploaded images - updated_params = save_uploaded_image(socket, component_params) + # Handle any uploaded files + updated_params = + socket + |> save_uploaded_image(component_params) + |> save_uploaded_datasheet(socket) - case Inventory.update_component(socket.assigns.editing_component, updated_params) do + case Inventory.update_component_with_datasheet(socket.assigns.editing_component, updated_params) do {:ok, _component} -> {:noreply, socket @@ -804,6 +819,51 @@ defmodule ComponentsElixirWeb.ComponentsLive do <% end %> +
+ +
+ <.live_file_input + upload={@uploads.datasheet} + class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-secondary/10 file:text-secondary hover:file:bg-secondary/20" + /> +
+

+ PDF files up to 10MB (or enter URL above to auto-download) +

+ + <%= for err <- upload_errors(@uploads.datasheet) do %> +

{Phoenix.Naming.humanize(err)}

+ <% end %> + + <%= for entry <- @uploads.datasheet.entries do %> +
+
+
+
+ <.icon name="hero-document-text" class="w-6 h-6 text-secondary" /> +
+
+
+

{entry.client_name}

+

{entry.progress}%

+
+
+ +
+ <% end %> + <%= for err <- upload_errors(@uploads.datasheet) do %> +

{upload_error_to_string(err)}

+ <% end %> +
+
+
+ + <%= if @editing_component && @editing_component.datasheet_filename do %> +
+

Current datasheet:

+ + <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> + View PDF + +
+ <% end %> +
+ <.live_file_input + upload={@uploads.datasheet} + class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-secondary/10 file:text-secondary hover:file:bg-secondary/20" + /> +
+

+ PDF files up to 10MB (leave empty to keep current, or enter URL above to auto-download) +

+ + <%= for err <- upload_errors(@uploads.datasheet) do %> +

{Phoenix.Naming.humanize(err)}

+ <% end %> + + <%= for entry <- @uploads.datasheet.entries do %> +
+
+
+
+ <.icon name="hero-document-text" class="w-6 h-6 text-secondary" /> +
+
+
+

{entry.client_name}

+

{entry.progress}%

+
+
+ +
+ <% end %> + <%= for err <- upload_errors(@uploads.datasheet) do %> +

{upload_error_to_string(err)}

+ <% end %> +
+
<% end %> + + <%= if component.datasheet_filename || component.datasheet_url do %> +
+ <.icon name="hero-document-text" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" /> +
+ Datasheet: +
+ <%= if component.datasheet_filename do %> +
+ + <.icon name="hero-document-arrow-down" class="w-4 h-4 mr-1" /> + View PDF (Downloaded) + +
+ <% end %> + <%= if component.datasheet_url do %> +
+ + <.icon name="hero-link" class="w-4 h-4 mr-1" /> + Original URL + +
+ <% end %> +
+
+
+ <% end %> @@ -1185,19 +1347,28 @@ defmodule ComponentsElixirWeb.ComponentsLive do

- <%= if component.datasheet_url do %> - + <%= cond do %> + <% component.datasheet_filename -> %> + + {component.name} + + <% component.datasheet_url -> %> + + {component.name} + + <% true -> %> {component.name} - - <% else %> - {component.name} <% end %>

- <%= if component.datasheet_url do %> + <%= if component.datasheet_url || component.datasheet_filename do %> 📄 <% end %>
@@ -1421,6 +1592,40 @@ defmodule ComponentsElixirWeb.ComponentsLive do result end + # Helper function for datasheet upload handling + defp save_uploaded_datasheet(component_params, socket) do + uploaded_files = + consume_uploaded_entries(socket, :datasheet, fn %{path: path}, entry -> + filename = "#{System.unique_integer([:positive])}_#{entry.client_name}" + uploads_dir = Application.get_env(:components_elixir, :uploads_dir) + upload_dir = Path.join([uploads_dir, "datasheets"]) + dest = Path.join(upload_dir, filename) + + # Ensure the upload directory exists + File.mkdir_p!(upload_dir) + + # Copy the file + case File.cp(path, dest) do + :ok -> + {:ok, filename} + + {:error, reason} -> + {:postpone, {:error, reason}} + end + end) + + case uploaded_files do + [filename] when is_binary(filename) -> + Map.put(component_params, "datasheet_filename", filename) + + [] -> + component_params + + _error -> + component_params + end + end + defp delete_image_file(nil), do: :ok defp delete_image_file(""), do: :ok diff --git a/lib/components_elixir_web/router.ex b/lib/components_elixir_web/router.ex index ea7b9a0..8953d7b 100644 --- a/lib/components_elixir_web/router.ex +++ b/lib/components_elixir_web/router.ex @@ -25,8 +25,9 @@ defmodule ComponentsElixirWeb.Router do get "/login/authenticate", AuthController, :authenticate post "/logout", AuthController, :logout - # File serving endpoint + # File serving endpoints get "/uploads/images/:filename", FileController, :show + get "/uploads/datasheets/:filename", FileController, :show_datasheet end scope "/", ComponentsElixirWeb do diff --git a/priv/repo/migrations/20250919205043_add_datasheet_filename_to_components.exs b/priv/repo/migrations/20250919205043_add_datasheet_filename_to_components.exs new file mode 100644 index 0000000..b802601 --- /dev/null +++ b/priv/repo/migrations/20250919205043_add_datasheet_filename_to_components.exs @@ -0,0 +1,9 @@ +defmodule ComponentsElixir.Repo.Migrations.AddDatasheetFilenameToComponents do + use Ecto.Migration + + def change do + alter table(:components) do + add :datasheet_filename, :string + end + end +end