Skip to content

Instantly share code, notes, and snippets.

@mikehostetler
Created August 29, 2025 10:53
Show Gist options
  • Save mikehostetler/fd377ee0649e76b2c18a2765032b8a6e to your computer and use it in GitHub Desktop.
Save mikehostetler/fd377ee0649e76b2c18a2765032b8a6e to your computer and use it in GitHub Desktop.
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
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