From elixir-phoenix-guide
Guides secure Phoenix LiveView file uploads with manual/auto configs, unique filenames, MIME validation, error handling, and static_paths setup. Invoke for upload or file serving features.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideThis skill uses the workspace's default tool permissions.
1. **Use manual uploads (NOT auto_upload: true)** for form submission patterns
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
allow_upload(:upload_name,
accept: ~w(.jpg .jpeg .png .pdf),
max_entries: 10,
max_file_size: 10_000_000
)
Template Requirements:
phx-submit event<.live_file_input> componentOnly use auto_upload: true when:
handle_progress/3 callback⚠️ Never use auto_upload: true with form submission patterns!
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:uploaded_files, [])
|> allow_upload(:photos,
accept: ~w(.jpg .jpeg .png),
max_entries: 5,
max_file_size: 10_000_000
)
{:ok, socket}
end
@impl true
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("save", _params, socket) do
uploaded_files =
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
dest = Path.join(["priv", "static", "uploads", safe_filename(entry.client_name)])
File.mkdir_p!(Path.dirname(dest))
File.cp!(path, dest)
{:ok, ~s(/uploads/#{Path.basename(dest)})}
end)
# Save to database with uploaded_files paths
{:noreply, assign(socket, :uploaded_files, uploaded_files)}
end
defp safe_filename(original_name) do
# Generate unique name to prevent collisions and attacks
ext = Path.extname(original_name)
"#{Ecto.UUID.generate()}#{ext}"
end
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:title]} label="Title" />
<div>
<.label>Upload Photos</.label>
<.live_file_input upload={@uploads.photos} />
</div>
<!-- Upload errors -->
<%= for err <- upload_errors(@uploads.photos) do %>
<p class="error"><%= error_to_string(err) %></p>
<% end %>
<!-- Entry previews and errors -->
<%= for entry <- @uploads.photos.entries do %>
<div>
<.live_img_preview entry={entry} />
<progress value={entry.progress} max="100"><%= entry.progress %>%</progress>
<%= for err <- upload_errors(@uploads.photos, entry) do %>
<p class="error"><%= error_to_string(err) %></p>
<% end %>
</div>
<% end %>
<:actions>
<.button phx-disable-with="Uploading...">Upload</.button>
</:actions>
</.simple_form>
Always implement error_to_string/1:
defp error_to_string(:too_large), do: "File is too large (max 10MB)"
defp error_to_string(:not_accepted), do: "File type not accepted"
defp error_to_string(:too_many_files), do: "Too many files selected"
defp error_to_string(:external_client_failure), do: "Upload failed"
Critical: After uploading files, they MUST be served via static_paths.
# lib/my_app_web.ex
def static_paths do
~w(assets fonts images uploads favicon.ico robots.txt)
end
Rule: Any directory you serve files from must be listed here.
# lib/my_app_web/endpoint.ex
plug Plug.Static,
at: "/",
from: :my_app,
gzip: false,
only: MyAppWeb.static_paths()
Static files must be in priv/static/:
my_app/
├── priv/
│ └── static/
│ ├── assets/ # CSS, JS (from esbuild)
│ ├── uploads/ # User uploads
│ │ ├── image1.jpg
│ │ └── doc.pdf
│ └── favicon.ico
<!-- Image -->
<img src="/uploads/photo.jpg" alt="Photo" />
<!-- Document download -->
<.link href="/uploads/document.pdf" download>Download</.link>
def download(conn, %{"filename" => filename}) do
# Sanitize filename to prevent path traversal
safe_name = Path.basename(filename)
path = Path.join(["priv", "static", "uploads", safe_name])
if File.exists?(path) and String.starts_with?(path, "priv/static/uploads") do
send_download(conn, {:file, path}, filename: safe_name)
else
conn
|> put_status(:not_found)
|> text("File not found")
end
end
For image uploads, show previews:
<%= for entry <- @uploads.photos.entries do %>
<div class="preview">
<.live_img_preview entry={entry} width={200} />
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref}>
Cancel
</button>
</div>
<% end %>
@impl true
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :photos, ref)}
end
You can have multiple upload configurations:
socket
|> allow_upload(:photos, accept: ~w(.jpg .jpeg .png), max_entries: 5)
|> allow_upload(:documents, accept: ~w(.pdf .docx), max_entries: 3)
For external storage, use the :external option:
allow_upload(:photos,
accept: ~w(.jpg .jpeg .png),
max_entries: 5,
external: &presign_upload/2
)
defp presign_upload(entry, socket) do
# Generate presigned URL for S3
{:ok, %{uploader: "S3", key: key, url: url}, socket}
end
Problem: Accessing /uploads/file.jpg returns 404
Fixes:
priv/static/uploads/# Debug helper
def check_static_file(path) do
full_path = Path.join(["priv", "static", path])
cond do
not File.exists?(full_path) ->
"File does not exist: #{full_path}"
not File.readable?(full_path) ->
"File exists but not readable: #{full_path}"
true ->
"File OK: #{full_path}"
end
end
Problem: Files serve correctly locally but fail in production
Fixes:
mix phx.digest before deployment:MIX_ENV=prod mix phx.digest
# config/runtime.exs
config :my_app, MyAppWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Check your deployment includes priv/static/
Never use user input directly in file paths:
# ❌ DANGEROUS - Path traversal attack
def serve_file(conn, %{"path" => user_path}) do
send_file(conn, 200, "priv/static/#{user_path}")
end
# ✅ SAFE - Validate and constrain
def serve_file(conn, %{"filename" => filename}) do
safe_name = Path.basename(filename) # Remove directory traversal
path = Path.join(["priv", "static", "uploads", safe_name])
if File.exists?(path) and String.starts_with?(path, "priv/static/uploads") do
send_file(conn, 200, path)
else
send_resp(conn, 404, "Not found")
end
end
Don't trust client MIME types:
def validate_file_type(path) do
# Use a library like `file_type` to verify actual content
case FileType.from_path(path) do
{:ok, %{mime_type: "image/" <> _}} -> :ok
_ -> {:error, :invalid_type}
end
end
Prevent collisions and path traversal:
defp safe_filename(original_name) do
ext = Path.extname(original_name)
"#{Ecto.UUID.generate()}#{ext}"
end
Set reasonable limits:
allow_upload(:photos,
accept: ~w(.jpg .jpeg .png),
max_entries: 5,
max_file_size: 10_000_000 # 10MB
)
Set proper content types to prevent XSS:
def serve_image(conn, %{"id" => id}) do
image = get_image!(id)
conn
|> put_resp_header("content-type", image.content_type)
|> put_resp_header("x-content-type-options", "nosniff")
|> send_file(200, image.path)
end
test "uploads image successfully", %{conn: conn} do
{:ok, lv, _html} = live(conn, "/gallery")
image =
file_input(lv, "#upload-form", :photos, [
%{
name: "test.png",
content: File.read!("test/fixtures/test.png"),
type: "image/png"
}
])
assert render_upload(image, "test.png") =~ "100%"
lv
|> form("#upload-form")
|> render_submit()
assert has_element?(lv, "img[alt='test.png']")
end
# DON'T DO THIS
allow_upload(:photos, auto_upload: true, ...)
def handle_event("save", _params, socket) do
consume_uploaded_entries(socket, :photos, ...) # Won't work!
end
# DO THIS
allow_upload(:photos, ...)
def handle_event("save", _params, socket) do
consume_uploaded_entries(socket, :photos, ...) # Works!
end
<!-- Missing error display -->
<.live_file_input upload={@uploads.photos} />
<.live_file_input upload={@uploads.photos} />
<%= for err <- upload_errors(@uploads.photos) do %>
<p class="error"><%= error_to_string(err) %></p>
<% end %>
# File saved to priv/static/uploads/
# But "uploads" not in static_paths
def static_paths, do: ~w(assets favicon.ico) # Missing uploads!
def static_paths, do: ~w(assets uploads favicon.ico)
# 1. Add directory to static_paths
def static_paths, do: ~w(assets uploads favicon.ico)
# 2. Create directory structure
priv/static/uploads/
# 3. Configure upload in mount
allow_upload(:photos, accept: ~w(.jpg .png), max_entries: 5)
# 4. Consume in handle_event
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
dest = Path.join(["priv", "static", "uploads", safe_filename(entry.client_name)])
File.mkdir_p!(Path.dirname(dest))
File.cp!(path, dest)
{:ok, "/uploads/#{Path.basename(dest)}"}
end)
# 5. Reference in templates
<img src="/uploads/#{filename}" />
# 6. Restart server to apply changes
mix phx.server
When writing tests for file upload functionality, invoke elixir-phoenix-guide:testing-essentials before writing any _test.exs file.