Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: phoenix-uploads description: MANDATORY for file upload features. Invoke before implementing upload or file serving functionality. file_patterns:
- "/live//*.ex"
- "**/*_live.ex"
- "/uploads//*.ex"
- "**/endpoint.ex"
- "**/*_web.ex"
auto_suggest: true
Phoenix File Uploads
RULES — Follow these with no exceptions
- Use manual uploads (NOT auto_upload: true) for form submission patterns
- Always add upload directory to static_paths() — files won't be accessible without this
- Handle upload errors — display error_to_string/1 output in templates
- Create upload directories with File.mkdir_p! before saving files
- Generate unique filenames — prevent collisions and path traversal attacks
- Validate file types server-side — never trust client MIME types
- Restart server after changing static_paths() — changes don't apply until restart
Upload Configuration
Manual Upload (Recommended for Most Cases)
allow_upload(:upload_name,accept: ~w(.jpg .jpeg .png .pdf),max_entries: 10,max_file_size: 10_000_000)
Template Requirements:
- Form with
phx-submitevent - Submit button to trigger upload
<.live_file_input>component- Progress indicators
Auto Upload (Advanced - Use Sparingly)
Only use auto_upload: true when:
- Files should upload immediately on selection
- You have
handle_progress/3callback - You consume entries outside form submission
⚠️ Never use auto_upload: true with form submission patterns!
Complete Upload Pattern
LiveView Module
@impl truedef mount(_params, _session, socket) dosocket =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 truedef handle_event("validate", _params, socket) do{:noreply, socket}end@impl truedef handle_event("save", _params, socket) douploaded_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)}enddefp safe_filename(original_name) do# Generate unique name to prevent collisions and attacksext = Path.extname(original_name)"#{Ecto.UUID.generate()}#{ext}"end
Template
<.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>
Error Handling
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"
Static File Serving Configuration
Critical: After uploading files, they MUST be served via static_paths.
Step 1: Define static_paths/0
# lib/my_app_web.exdef static_paths do~w(assets fonts images uploads favicon.ico robots.txt)end
Rule: Any directory you serve files from must be listed here.
Step 2: Verify Plug.Static Configuration
# lib/my_app_web/endpoint.explug Plug.Static,at: "/",from: :my_app,gzip: false,only: MyAppWeb.static_paths()
File Structure
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
Serving Uploaded Files
From Templates
<!-- Image --><img src="/uploads/photo.jpg" alt="Photo" /><!-- Document download --><.link href="/uploads/document.pdf" download>Download</.link>
From Controllers
def download(conn, %{"filename" => filename}) do# Sanitize filename to prevent path traversalsafe_name = Path.basename(filename)path = Path.join(["priv", "static", "uploads", safe_name])if File.exists?(path) and String.starts_with?(path, "priv/static/uploads") dosend_download(conn, {:file, path}, filename: safe_name)elseconn|> put_status(:not_found)|> text("File not found")endend
Image Previews
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 truedef handle_event("cancel-upload", %{"ref" => ref}, socket) do{:noreply, cancel_upload(socket, :photos, ref)}end
Multiple Upload Slots
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)
External Storage (S3, etc.)
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
Troubleshooting
Files Return 404
Problem: Accessing /uploads/file.jpg returns 404
Fixes:
- Check static_paths includes "uploads"
- Verify file exists in
priv/static/uploads/ - Restart server after changing static_paths
- Check file permissions (should be readable)
# Debug helperdef check_static_file(path) dofull_path = Path.join(["priv", "static", path])cond donot 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}"endend
Files Work in Dev but Not Production
Problem: Files serve correctly locally but fail in production
Fixes:
- Run `mix phx.digest` before deployment:
MIX_ENV=prod mix phx.digest
- Check production endpoint config:
# config/runtime.exsconfig :my_app, MyAppWeb.Endpoint,cache_static_manifest: "priv/static/cache_manifest.json"
- Ensure files are deployed:
# Check your deployment includes priv/static/
Security Best Practices
1. Sanitize File Paths
Never use user input directly in file paths:
# ❌ DANGEROUS - Path traversal attackdef serve_file(conn, %{"path" => user_path}) dosend_file(conn, 200, "priv/static/#{user_path}")end# ✅ SAFE - Validate and constraindef serve_file(conn, %{"filename" => filename}) dosafe_name = Path.basename(filename) # Remove directory traversalpath = Path.join(["priv", "static", "uploads", safe_name])if File.exists?(path) and String.starts_with?(path, "priv/static/uploads") dosend_file(conn, 200, path)elsesend_resp(conn, 404, "Not found")endend
2. Validate File Types
Don't trust client MIME types:
def validate_file_type(path) do# Use a library like `file_type` to verify actual contentcase FileType.from_path(path) do{:ok, %{mime_type: "image/" <> _}} -> :ok_ -> {:error, :invalid_type}endend
3. Generate Unique Filenames
Prevent collisions and path traversal:
defp safe_filename(original_name) doext = Path.extname(original_name)"#{Ecto.UUID.generate()}#{ext}"end
4. Limit File Sizes
Set reasonable limits:
allow_upload(:photos,accept: ~w(.jpg .jpeg .png),max_entries: 5,max_file_size: 10_000_000 # 10MB)
5. Content-Type Headers
Set proper content types to prevent XSS:
def serve_image(conn, %{"id" => id}) doimage = 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
Testing Uploads
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
Common Pitfalls
❌ Using auto_upload with form submit
# DON'T DO THISallow_upload(:photos, auto_upload: true, ...)def handle_event("save", _params, socket) doconsume_uploaded_entries(socket, :photos, ...) # Won't work!end
✅ Use manual upload instead
# DO THISallow_upload(:photos, ...)def handle_event("save", _params, socket) doconsume_uploaded_entries(socket, :photos, ...) # Works!end
❌ Not handling upload errors
<!-- Missing error display --><.live_file_input upload={@uploads.photos} />
✅ Always show errors
<.live_file_input upload={@uploads.photos} /><%= for err <- upload_errors(@uploads.photos) do %><p class="error"><%= error_to_string(err) %></p><% end %>
❌ Forgetting static_paths
# File saved to priv/static/uploads/# But "uploads" not in static_pathsdef static_paths, do: ~w(assets favicon.ico) # Missing uploads!
✅ Include upload directory
def static_paths, do: ~w(assets uploads favicon.ico)
Quick Reference
# 1. Add directory to static_pathsdef static_paths, do: ~w(assets uploads favicon.ico)# 2. Create directory structurepriv/static/uploads/# 3. Configure upload in mountallow_upload(:photos, accept: ~w(.jpg .png), max_entries: 5)# 4. Consume in handle_eventconsume_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 changesmix phx.server
Testing
When writing tests for file upload functionality, invoke elixir-phoenix-guide:testing-essentials before writing any _test.exs file.