Created
August 29, 2025 10:53
-
-
Save mikehostetler/fd377ee0649e76b2c18a2765032b8a6e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| defmodule PetalPro.FileUploads.Tigris do | |
| @moduledoc """ | |
| Tigris.dev S3-compatible storage provider using ExAws. | |
| ## Setup | |
| 1. Add ExAws dependencies to mix.exs: | |
| ```elixir | |
| {:ex_aws, "~> 2.0"}, | |
| {:ex_aws_s3, "~> 2.0"}, | |
| {:hackney, "~> 1.9"}, | |
| {:sweet_xml, "~> 0.6.6"}, | |
| {:jason, "~> 1.1"} | |
| ``` | |
| 2. Configure in config/runtime.exs: | |
| ```elixir | |
| config :ex_aws, | |
| debug_requests: true, | |
| json_codec: Jason, | |
| access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), | |
| secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") | |
| config :ex_aws, :s3, | |
| scheme: "https://", | |
| host: "assets.mike-hostetler.com", | |
| region: "auto" | |
| ``` | |
| 3. Set environment variables: | |
| ```bash | |
| export AWS_ACCESS_KEY_ID="your_access_key" | |
| export AWS_SECRET_ACCESS_KEY="your_secret_key" | |
| ``` | |
| """ | |
| require Logger | |
| @spec presign_upload(map(), map()) :: {:ok, map(), map()} | {:error, term()} | |
| def presign_upload(entry, socket) do | |
| uploads = socket.assigns.uploads | |
| path = PetalPro.config([:tigris, :path]) | |
| key = Ecto.UUID.generate() <> Path.extname(entry.client_name) | |
| content_type = entry.client_type | |
| host = Application.get_env(:ex_aws, :s3)[:host] | |
| scheme = Application.get_env(:ex_aws, :s3)[:scheme] || "https://" | |
| scheme = String.trim_trailing(scheme, "/") | |
| # Use PUT presigned URL with Content-Type in signature | |
| case :s3 | |
| |> ExAws.Config.new() | |
| |> ExAws.S3.presigned_url(:put, path, key, | |
| expires_in: 3600, | |
| headers: [{"content-type", content_type}] | |
| ) do | |
| {:ok, presigned_url} -> | |
| meta = %{ | |
| uploader: "DirectUploader", | |
| key: key, | |
| url: presigned_url, | |
| fields: %{ | |
| "Content-Type" => content_type | |
| } | |
| } | |
| Logger.debug("Generated presigned URL for Tigris upload: #{inspect(meta)}") | |
| {:ok, meta, socket} | |
| {:error, error} -> | |
| Logger.error("Failed to generate presigned URL for Tigris: #{inspect(error)}") | |
| {:error, error} | |
| end | |
| end | |
| @spec consume_uploaded_entries(Phoenix.LiveView.Socket.t(), any) :: list | |
| def consume_uploaded_entries(socket, uploads_key) do | |
| Phoenix.LiveView.consume_uploaded_entries(socket, uploads_key, fn upload, _entry -> | |
| case upload do | |
| %{fields: %{"Content-Type" => _content_type}, key: key} -> | |
| # Handle external upload (DirectUploader) | |
| {:ok, file_id_to_url(key)} | |
| %{fields: %{"key" => key}} -> | |
| # Handle external upload (ExternalUploader) | |
| {:ok, file_id_to_url(key)} | |
| %{path: path} -> | |
| # Handle local upload | |
| key = Ecto.UUID.generate() <> Path.extname(path) | |
| base_path = PetalPro.config([:tigris, :path]) | |
| content_type = MIME.from_path(path) | |
| case File.read(path) do | |
| {:ok, file_binary} -> | |
| base_path | |
| |> ExAws.S3.put_object(key, file_binary, content_type: content_type) | |
| |> ExAws.request() | |
| |> case do | |
| {:ok, _response} -> | |
| {:ok, file_id_to_url(key)} | |
| {:error, error} -> | |
| Logger.error("Failed to upload file to Tigris: #{inspect(error)}") | |
| {:error, "Failed to upload file"} | |
| end | |
| {:error, error} -> | |
| Logger.error("Failed to read file for Tigris upload: #{inspect(error)}") | |
| {:error, "Failed to read file"} | |
| end | |
| end | |
| end) | |
| end | |
| @spec file_id_to_url(String.t()) :: String.t() | |
| defp file_id_to_url(key) do | |
| path = PetalPro.config([:tigris, :path]) | |
| host = Application.get_env(:ex_aws, :s3)[:host] | |
| scheme = Application.get_env(:ex_aws, :s3)[:scheme] || "https://" | |
| # Remove trailing slash from scheme if present | |
| scheme = String.trim_trailing(scheme, "/") | |
| Logger.debug("Generating URL: scheme=#{scheme}, host=#{host}, path=#{path}, key=#{key}") | |
| "#{scheme}/#{host}/#{path}/#{key}" | |
| end | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const ExternalUploader = function (entries, onViewError) { | |
| entries.forEach((entry) => { | |
| let formData = new FormData(); | |
| let { url, fields } = entry.meta; | |
| Object.entries(fields).forEach(([key, value]) => { | |
| formData.append(key, value); | |
| }); | |
| formData.append("file", entry.file); | |
| // Build the request | |
| let req = new XMLHttpRequest(); | |
| onViewError(() => req.abort()); | |
| req.onload = () => { | |
| req.status >= 200 && req.status < 300 | |
| ? entry.progress(100) | |
| : entry.error(); | |
| }; | |
| req.onerror = () => { | |
| entry.error(); | |
| }; | |
| // Adds an event listener for upload progress, to enable an upload progress bar | |
| req.upload.addEventListener("progress", (event) => { | |
| if (event.lengthComputable) { | |
| const progressPercent = Math.round((event.loaded / event.total) * 100); | |
| if (progressPercent < 100) { | |
| entry.progress(progressPercent); | |
| } | |
| } | |
| }); | |
| req.open("POST", url, true); | |
| req.send(formData); | |
| }); | |
| }; | |
| const DirectUploader = function (entries, onViewError) { | |
| entries.forEach((entry) => { | |
| let { url, fields } = entry.meta; | |
| // Build the request | |
| let req = new XMLHttpRequest(); | |
| onViewError(() => req.abort()); | |
| req.onload = () => { | |
| req.status >= 200 && req.status < 300 | |
| ? entry.progress(100) | |
| : entry.error(); | |
| }; | |
| req.onerror = () => { | |
| entry.error(); | |
| }; | |
| // Adds an event listener for upload progress, to enable an upload progress bar | |
| req.upload.addEventListener("progress", (event) => { | |
| if (event.lengthComputable) { | |
| const progressPercent = Math.round((event.loaded / event.total) * 100); | |
| if (progressPercent < 100) { | |
| entry.progress(progressPercent); | |
| } | |
| } | |
| }); | |
| req.open("PUT", url, true); | |
| // Set Content-Type header AFTER opening the request | |
| if (fields["Content-Type"]) { | |
| req.setRequestHeader("Content-Type", fields["Content-Type"]); | |
| } | |
| req.send(entry.file); | |
| }); | |
| }; | |
| export default { | |
| ExternalUploader, | |
| DirectUploader, | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment