defmodule Bow do @moduledoc """ Bow - the file uploader ## Global Configuration config :bow, storage: Bow.Storage.Local, # storage adapter; Bow.Storage.Local or Bow.Storage.S3 storage_prefix: "priv/static/uploads", # storage directory prefix store_timeout: 30_000, # single version upload timeout exec_timeout: 15_000, # single command execution timeout """ def storage, do: Application.get_env(:bow, :storage, Bow.Storage.Local) def store_timeout, do: Application.get_env(:bow, :store_timeout, 30_000) def version_timeout, do: Application.get_env(:bow, :version_timeout, 60_000) @type t :: %__MODULE__{ # "cat.jpg", "README" name: String.t(), # "cat", "README" rootname: String.t(), # ".jpg", "" ext: String.t(), path: String.t() | nil, scope: any, uploader: atom } @typep opts :: keyword @enforce_keys [:name, :rootname, :ext] defstruct name: "", rootname: "", ext: nil, path: nil, scope: nil, uploader: nil defmodule Error do defexception message: "" end @doc """ Process & store given file with its uploader """ @spec store(t, opts) :: {:ok, map} | {:error, map} def store(file, opts \\ []) do uploader = file.uploader versions = uploader.versions(file) make(uploader, file, file, versions, opts) |> combine_results end @doc """ Load given file """ @spec load(t, opts) :: {:ok, t} | {:error, any} def load(file, opts \\ []) do with {:ok, path} <- load_file(file.uploader, file, opts) do {:ok, %{file | path: path}} end end @doc """ Delete all versions of given file """ @spec delete(t, opts) :: :ok def delete(file, opts \\ []) do uploader = file.uploader for version <- uploader.versions(file) do name = uploader.filename(file, version) delete_file(uploader, set(file, :name, name), opts) end :ok end @doc """ Regenerate file using different uploader """ @spec regenerate(t) :: {:ok, map} | {:error, any} def regenerate(file) do with {:ok, file} <- load(file), do: store(file) end @doc """ Copy file """ @spec copy(src :: t, dst :: t, opts) :: {:ok, map} | {:error, any} def copy(src, dst, opts \\ []) do if src.uploader == dst.uploader do uploader = src.uploader src |> uploader.versions() |> Enum.map(fn version -> src_file = set(src, :name, uploader.filename(src, version)) dst_file = set(dst, :name, uploader.filename(dst, version)) {version, copy_file(uploader, src_file, dst_file, opts)} end) |> combine_results() else {:error, :uploader_mismatch} end end defp make(up, f0, fx, versions, opts) when is_list(versions) do versions |> Enum.map(&Task.async(fn -> make(up, f0, fx, &1, opts) end)) |> Enum.map(&Task.await(&1, version_timeout())) |> List.flatten() end defp make(up, f0, fx, version, opts) do fy = fx |> set(:name, up.filename(f0, version)) |> set(:path, nil) case transform(up, fx, fy, version) do {:ok, fy, next_versions} -> res0 = Task.async(fn -> store_file(up, fy, opts) end) res1 = make(up, f0, fy, next_versions, opts) [{version, Task.await(res0, store_timeout())} | res1] {:ok, fy} -> [{version, store_file(up, fy, opts)}] :ok -> [{version, {:ok, :no_store}}] {:error, reason} -> [{version, {:error, reason}}] end end defp transform(up, fx, fy, version) do up.transform(fx, fy, version) rescue ex -> {:error, ex} end defp store_file(uploader, file, opts) do storage().store( file.path, uploader.store_dir(file), file.name, opts ++ uploader.store_options(file) ) end defp load_file(uploader, file, opts) do storage().load( uploader.store_dir(file), file.name, opts ) end defp delete_file(uploader, file, opts) do storage().delete( uploader.store_dir(file), file.name, opts ) end defp copy_file(uploader, src, dst, opts) do storage().copy( uploader.store_dir(src), src.name, uploader.store_dir(dst), dst.name, opts ++ uploader.store_options(dst) ) end @spec url(t | nil) :: String.t() | nil def url(file), do: url(file, []) @spec url(t | nil, atom | list) :: String.t() | nil def url(file, opts) when is_list(opts), do: url(file, :original, opts) def url(file, version), do: url(file, version, []) @spec url(t | nil, atom, list) :: String.t() | nil def url(nil, _version, _opts), do: nil def url(file, version, opts) do assets_host = file.uploader.assets_host() opts = opts |> Keyword.put(:assets_host, assets_host) storage().url( file.uploader.store_dir(file), file.uploader.filename(file, version), opts ) end @spec new(keyword) :: t def new(args) do {name, path} = case {args[:name], args[:path]} do {nil, nil} -> raise Error, message: "Missing :name or :path attributes when creating new Bow file" {nil, path} -> {basename(path), path} {name, path} -> {name, path} end args = Keyword.merge(args, path: path, name: name, rootname: rootname(name), ext: extname(name) ) struct!(__MODULE__, args) end defp basename(name), do: name |> Path.basename() defp rootname(name), do: name |> Path.rootname() defp extname(name), do: name |> Path.extname() |> String.downcase() @spec set(t, atom, any) :: t def set(file, :name, name), do: %{file | name: name, rootname: rootname(name), ext: extname(name)} def set(file, :rootname, rootname), do: %{file | name: rootname <> file.ext, rootname: rootname} def set(file, :ext, ext), do: %{file | name: file.rootname <> ext, ext: ext} def set(file, key, value), do: struct(file, [{key, value}]) @doc false @spec combine_results(list) :: {:ok, map} | {:error, map} def combine_results(results) do Enum.reduce(results, {:ok, %{}}, fn {key, {:error, reason}}, {_, map} -> {:error, Map.put(map, key, {:error, reason})} {key, value}, {ok, map} -> {ok, Map.put(map, key, value)} end) end end