code-quality
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when implementing file uploads or serving uploaded files — allow_upload, consume_uploaded_entries, content-type allowlists, validators, local vs S3/external storage, authorized downloads.
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-uploads --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/phoenix-uploadsContext preview
The summary Claude sees to decide when to auto-load this skill.
Use when implementing file uploads or serving uploaded files — allow_upload, consume_uploaded_entries, content-type allowlists, validators, local vs S3/external storage, authorized downloads.
name: phoenix-uploads description: "Use when implementing file uploads or serving uploaded files — allow_upload, consume_uploaded_entries, content-type allowlists, validators, local vs S3/external storage, authorized downloads."
> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2, Ecto 3.14 — October 2026
1. **Never take the stored extension or the served content type from the client.** LiveView accepts an entry when its client MIME type **or** its file extension matches `:accept`, so `x.html` sent as `image/png` passes. Sniff the magic bytes of the uploaded file and pick the extension from an allowlist keyed on the detected type. 2. **Name files on the server** — `Ecto.UUID.generate() <> allowlisted_ext`. Never put `entry.client_name` (or any request param) in a path. 3. **Store under a configured directory, not a cwd-relative `"priv/static/uploads"`.** `priv/static` is replaced on every release deploy and runtime files are never in the digest manifest. Read the directory from app config at runtime and serve it with its own `Plug.Static` (or use object storage via `:external`). 4. **Private files go through a controller that loads the record through the scope** — `Media.get_document!(scope, id)`. Never accept a filename or path from the request. Send an allowlisted `content_type`, `disposition: :attachment` and `x-content-type-options: nosniff`. 5. **The `consume_uploaded_entries/3` callback must return `{:ok, value}` or `{:postpone, value}`.** Wrap the context result (`{:ok, Media.create_image(...)}`) and handle failures after consuming. 6. **Render `upload_errors/1` and `upload_errors/2`, and make `error_to_string/1` total** — atoms, `{:external_metadata_failure, _}`, `{:writer_failure, _}`, your `validator:` reasons, and a catch-all. 7. **Form markup is Phoenix 1.8:** assign `:form` in `mount`, give `<.form>` an `id`, put `<.button>` directly inside it (`<.form>` has no `actions` slot), and use a plain `<label for={@uploads.images.ref}>` (1.8 `core_components` has no `label/1` component). 8. **Default to manual uploads.** With `auto_upload: true`, `consume_uploaded_entries/3` raises while any entry is still in progress; consume from a `progress:` callback once `entry.done?`. 9. **External uploads need both halves:** the `:external` function returns meta with an `:uploader` name, and that name must be registered in `uploaders:` on the JS `LiveSocket`.
---
The `MyApp.Media.Image` schema is the one in the `ecto-essentials` skill: `title`, `filename` (server-generated), `original_name` (display only), `content_type`, `byte_size`, `folder_id`, `user_id`. Its `changeset/3` casts only `[:title, :original_name]`.
defmodule MyApp.Media do
alias MyApp.Accounts.Scope
alias MyApp.Media.Image
alias MyApp.Repo
# Detected content type => extension we store under. The client's name and
# type never decide either.
@exts %{"image/jpeg" => ".jpg", "image/png" => ".png", "image/webp" => ".webp"}
def allowed_type?(content_type), do: Map.has_key?(@exts, content_type)
def create_image(%Scope{} = scope, tmp_path, attrs) do
with {:ok, stored} <- store_file(tmp_path) do
# Storage fields are set on the struct, never cast from attrs.
changeset = Image |> struct!(stored) |> Image.changeset(attrs, scope)
case Repo.insert(changeset) do
{:ok, image} ->
{:ok, image}
{:error, changeset} ->
# Don't leave an orphaned file behind a failed insert.
remove_file(stored.filename)
{:error, changeset}
end
end
end
def delete_image(%Scope{} = scope, %Image{} = image) do
true = image.user_id == scope.user.id
with {:ok, image} <- Repo.delete(image) do
remove_file(image.filename)
{:ok, image}
end
end
# list_images/2, get_image!/2, update_image/3: see the ecto-essentials skill.
def image_url(%Image{filename: filename}), do: "/uploads/" <> filename
# Resolved at runtime, so prod can point at a mounted volume (config/runtime.exs).
def uploads_dir, do: Application.fetch_env!(:my_app, :uploads_dir)
defp store_file(tmp_path) do
with {:ok, content_type} <- sniff_image(tmp_path) do
filename = Ecto.UUID.generate() <> Map.fetch!(@exts, content_type)
File.mkdir_p!(uploads_dir())
File.cp!(tmp_path, Path.join(uploads_dir(), filename))
{:ok, %{filename: filename, content_type: content_type, byte_size: File.stat!(tmp_path).size}}
end
end
defp remove_file(filename), do: File.rm(Path.join(uploads_dir(), filename))
# Magic bytes from the file itself.
defp sniff_image(path) do
case File.open(path, [:read, :binary], &IO.binread(&1, 12)) do
{:ok, <<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>} -> {:ok, "image/png"}
{:ok, <<0xFF, 0xD8, 0xFF, _::binary>>} -> {:ok, "image/jpeg"}
{:ok, <<"RIFF", _::binary-size(4), "WEBP", _::binary>>} -> {:ok, "image/webp"}
_ -> {:error, :invalid_content}
end
end
endFor more formats, the `file_type` package sniffs many types: `FileType.from_path(path)` returns `{:ok, {"png", "image/png"}}` or `{:error, reason}`. Still map the detected MIME type through your own allowlist. Broadcasts (`{:created, image}` etc.) follow the `phoenix-pubsub-patterns` skill.
defmodule MyAppWeb.ImageLive.Index do
use MyAppWeb, :live_view
alias MyApp.Media
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:form, to_form(%{"title" => ""}, as: :image))
|> stream(:images, Media.list_images(socket.assigns.current_scope))
|> allow_upload(:images,
accept: ~w(.jpg .jpeg .png .webp),
max_entries: 5,
max_file_size: 10_000_000,
# Runs on client metadata, before any bytes arrive: early feedback only.
# The magic-byte check in the cont· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when preparing releases or deployment config — mix phx.gen.release, runtime.exs vs compile-time config, release migrations,…
Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date…
Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes,…
Use when migrating PostgreSQL tables that serve live traffic — index concurrently,…
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop…