diff --git a/README.md b/README.md index 28ed902..7eced3d 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,16 @@ And then you can use it with `Majic.perform/2` with `pool: YourApp.MajicPool` op iex(1)> Majic.perform(Path.expand("~/.bash_history"), pool: YourApp.MajicPool) {:ok, %Majic.Result{mime_type: "text/plain", encoding: "us-ascii", content: "ASCII text"}} ``` +#### Fixing extensions + +You may also want to fix the user-provided filename according to its detected MIME type. To do this, you can use `Majic.Extension.fix/3`: + +```elixir +iex(1)> {:ok, result} = Majic.perform("cat.jpeg", once: true) +{:ok, %Majic.Result{mime_type: "image/webp", ...}} +iex(1)> Majic.Extension.fix("cat.jpeg", result) +"cat.webp" +``` #### Use with Plug.Upload @@ -152,7 +162,8 @@ If you use Plug or Phoenix, you may want to automatically verify the content typ `Majic.Plug` is there for this. Enable it by using `plug Majic.Plug, pool: YourApp.MajicPool` in your pipeline or controller. Then, every `Plug.Upload` -in `conn.params` is now verified. The filename is also altered with an extension matching its content-type. +in `conn.params` and `conn.body_params` is now verified. The filename is also altered with an extension matching its +content-type, using `Majic.Extension`. ## Notes diff --git a/lib/majic/extension.ex b/lib/majic/extension.ex new file mode 100644 index 0000000..fd2f269 --- /dev/null +++ b/lib/majic/extension.ex @@ -0,0 +1,89 @@ +defmodule Majic.Extension do + @moduledoc """ + Helper module to fix extensions. Uses [MIME](https://hexdocs.pm/mime/MIME.html). + """ + + @typedoc """ + If an extension is defined for a given MIME type, append it to the previous extension. + + If no extension could be found for the MIME type, and `subtype_as_extension: false`, the returned filename will have no extension. + """ + @type option_append :: {:append, false | true} + + @typedoc "If no extension is defined for a given MIME type, use the subtype as its extension." + @type option_subtype_as_extension :: {:subtype_as_extension, false | true} + + @spec fix(Path.t(), Majic.Result.t() | String.t(), [ + option_append() | option_subtype_as_extension() + ]) :: Path.t() + @doc """ + Fix `name`'s extension according to `result_or_mime_type`. + + ```elixir + iex(1)> {:ok, result} = Majic.perform("cat.jpeg", once: true) + {:ok, %Majic.Result{mime_type: "image/webp", ...}} + iex(1)> Majic.Extension.fix("cat.jpeg", result) + "cat.webp" + ``` + + The `append: true` option will append the correct extension to the user-provided one, if there's an extension for the + type: + + ``` + iex(1)> Majic.Extension.fix("cat.jpeg", result, append: true) + "cat.jpeg.webp" + iex(2)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", append: true) + "Makefile" + ``` + + The `subtype_as_extension: true` option will use the subtype part of the MIME type as an extension for the ones that + don't have any: + + ```elixir + iex(1)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true) + "Makefile.x-makefile" + iex(1)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true, append: true) + "Makefile.txt.x-makefile" + ``` + """ + def fix(name, result_or_mime_type, options \\ []) + + def fix(name, %Majic.Result{mime_type: mime_type}, options) do + do_fix(name, mime_type, options) + end + + def fix(name, mime_type, options) do + do_fix(name, mime_type, options) + end + + defp do_fix(name, mime_type, options) do + append? = Keyword.get(options, :append, false) + subtype? = Keyword.get(options, :subtype_as_extension, false) + exts = MIME.extensions(mime_type) ++ subtype_extension(subtype?, mime_type) + old_ext = String.downcase(Path.extname(name)) + basename = Path.basename(name, old_ext) + "." <> old = old_ext + + if old in exts do + Enum.join([basename, old]) + else + ext = List.first(exts) + + ext_list = + cond do + ext && append? -> [old, ext] + !ext -> [] + ext -> [ext] + end + + Enum.join([basename] ++ ext_list, ".") + end + end + + defp subtype_extension(true, type) do + [_type, sub] = String.split(type, "/", parts: 2) + [sub] + end + + defp subtype_extension(_, _), do: [] +end diff --git a/lib/majic/plug.ex b/lib/majic/plug.ex index 311d20b..99d78cc 100644 --- a/lib/majic/plug.ex +++ b/lib/majic/plug.ex @@ -10,22 +10,22 @@ if Code.ensure_loaded?(Plug) do One of the required option of `pool`, `server` or `once` must be set. Additional options: - * `fix_extension`, default true: rewrite the user provided `filename` with a valid extension for the detected content type - * `append_extension`, default false: append the valid extension to the previous filename, without removing the user provided extension + * `fix_extension`, default true: enable use of `Majic.Extension`, + * options for `Majic.Extension`. - To use a gen_magic pool: + To use a majic pool: ``` plug Majic.Plug, pool: MyApp.MajicPool ``` - To use a single gen_magic server: + To use a single majic server: ``` plug Majic.Plug, server: MyApp.MajicServer ``` - To start a gen_magic process at each file (not recommended): + To start a majic process at each file (not recommended): ``` plug Majic.Plug, once: true @@ -44,7 +44,8 @@ if Code.ensure_loaded?(Plug) do opts |> Keyword.put_new(:fix_extension, true) - |> Keyword.put_new(:append_extension, false) + |> Keyword.put_new(:append, false) + |> Keyword.put_new(:subtype_as_extension, false) end @impl Plug @@ -105,71 +106,18 @@ if Code.ensure_loaded?(Plug) do end defp fix_upload(upload, magic, opts) do - %{upload | content_type: magic.mime_type} - |> fix_extension(Keyword.get(opts, :fix_extension), opts) - end + filename = + if Keyword.get(opts, :fix_extension) do + IO.puts("FIXING EXTENSION #{inspect opts}") + ext_opts = [ + append: Keyword.get(opts, :append, false), + subtype_as_extension: Keyword.get(opts, :subtype_as_extension, false) + ] - defp fix_extension(upload, true, opts) do - old_ext = String.downcase(Path.extname(upload.filename)) - extensions = MIME.extensions(upload.content_type) - rewrite_extension(upload, old_ext, extensions, opts) - end + Majic.Extension.fix(upload.filename, magic, ext_opts) + end - defp fix_extension(upload, _, _) do - upload - end - - defp rewrite_extension(upload, old, [ext | _] = exts, opts) do - if old in exts do - upload - else - basename = Path.basename(upload.filename, old) - - %{ - upload - | filename: - rewrite_or_append_extension( - basename, - old, - ext, - Keyword.get(opts, :append_extension) - ) - } - end - end - - # No extension for type. - defp rewrite_extension(upload, old, [], opts) do - %{ - upload - | filename: - rewrite_or_append_extension( - Path.basename(upload.filename, old), - old, - nil, - Keyword.get(opts, :append_extension) - ) - } - end - - # Append, no extension for type: keep old extension - defp rewrite_or_append_extension(basename, "." <> old, nil, true) do - basename <> "." <> old - end - - # No extension for type: only keep basename - defp rewrite_or_append_extension(basename, _, nil, _) do - basename - end - - # Append - defp rewrite_or_append_extension(basename, "." <> old, ext, true) do - Enum.join([basename, old, ext], ".") - end - - # Rewrite - defp rewrite_or_append_extension(basename, _, ext, _) do - basename <> "." <> ext + %{upload | content_type: magic.mime_type, filename: filename || upload.filename} end # put value at path in conn. diff --git a/mix.exs b/mix.exs index 2e0a321..f516fd3 100644 --- a/mix.exs +++ b/mix.exs @@ -43,8 +43,8 @@ defmodule Majic.MixProject do defp deps do [ {:nimble_pool, "~> 0.1"}, + {:mime, "~> 1.0"}, {:plug, "~> 1.0", optional: true}, - {:mime, "~> 1.0", optional: true}, {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, diff --git a/test/majic/extension_test.exs b/test/majic/extension_test.exs new file mode 100644 index 0000000..2e375a1 --- /dev/null +++ b/test/majic/extension_test.exs @@ -0,0 +1,33 @@ +defmodule Majic.ExtensionTest do + use ExUnit.Case + + alias Majic.Extension + + test "it fixes extensions" do + assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile") + assert "cat.webp" == Extension.fix("cat.jpeg", "image/webp") + end + + test "it appends extensions" do + assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile", append: true) + assert "cat.jpeg.webp" == Extension.fix("cat.jpeg", "image/webp", append: true) + end + + test "it uses subtype as extension" do + assert "Makefile.x-makefile" == + Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true) + + assert "cat.webp" == Extension.fix("cat.jpeg", "image/webp", subtype_as_extension: true) + end + + test "it appends and use subtype" do + assert "Makefile.txt.x-makefile" == + Extension.fix("Makefile.txt", "text/x-makefile", + subtype_as_extension: true, + append: true + ) + + assert "cat.jpeg.webp" == + Extension.fix("cat.jpeg", "image/webp", subtype_as_extension: true, append: true) + end +end diff --git a/test/majic/plug_test.exs b/test/majic/plug_test.exs index 192cd3c..fed945e 100644 --- a/test/majic/plug_test.exs +++ b/test/majic/plug_test.exs @@ -59,6 +59,11 @@ defmodule Majic.PlugTest do Content-Type: image/jpg\r \r #{File.read!("test/fixtures/cat.webp")}\r + ------w58EW1cEpjzydSCq\r + Content-Disposition: form-data; name=\"cats[][inception][cat]\"; filename*=\"utf-8''third-cute-cat.jpg\"\r + Content-Type: image/jpg\r + \r + #{File.read!("test/fixtures/cat.webp")}\r ------w58EW1cEpjzydSCq--\r """ @@ -69,7 +74,7 @@ defmodule Majic.PlugTest do plug = Majic.Plug.init(once: true) plug_no_ext = Majic.Plug.init(once: true, fix_extension: false) - plug_append_ext = Majic.Plug.init(once: true, fix_extension: true, append_extension: true) + plug_append_ext = Majic.Plug.init(once: true, fix_extension: true, append: true) conn = Majic.Plug.call(orig_conn, plug) conn_no_ext = Majic.Plug.call(orig_conn, plug_no_ext) @@ -84,7 +89,7 @@ defmodule Majic.PlugTest do assert get_in(conn.params, ["form", "makefile"]).content_type == "text/x-makefile" assert get_in(conn.params, ["form", "makefile"]).filename == "mymakefile" assert get_in(conn_no_ext.params, ["form", "makefile"]).filename == "mymakefile.txt" - assert get_in(conn_append_ext.params, ["form", "makefile"]).filename == "mymakefile.txt" + assert get_in(conn_append_ext.params, ["form", "makefile"]).filename == "mymakefile" assert get_in(conn.body_params, ["form", "make", "file"]) == get_in(conn.params, ["form", "make", "file"]) @@ -98,8 +103,9 @@ defmodule Majic.PlugTest do assert get_in(conn_append_ext.params, ["cat"]).filename == "cute-cat.jpg.webp" assert Enum.all?(conn.params["cats"], fn - %Plug.Upload{} = upload -> upload.content_type == "image/webp" - _ -> true - end) + %Plug.Upload{} = upload -> upload.content_type == "image/webp" + %{"inception" => %{"cat" => upload}} -> upload.content_type == "image/webp" + _ -> true + end) end end