Current section
2 Versions
Jump to
Current section
2 Versions
Compare versions
15
files changed
+577
additions
-494
deletions
| @@ -1,10 +1,8 @@ | |
| 1 1 | # Dropkick |
| 2 2 | |
| 3 | - Dropkick is a highly experimental library that provides easy to use uploads for the Elixir/ Phoenix ecosystem. |
| 3 | + Dropkick is a highly experimental library that provides easy to use uploads for the Elixir/ Phoenix ecosystem. |
| 4 4 | This is a opinionated library focused on developer ergonomics that you can use to provide file uploads in any Phoenix project. |
| 5 5 | |
| 6 | - Some inspiration was taken from other projects like [Capsule](https://github.com/elixir-capsule/capsule) and [Waffle](https://github.com/elixir-waffle/waffle) as well as Ruby's [Shrine](https://shrinerb.com/). |
| 7 | - |
| 8 6 | ## Installation |
| 9 7 | |
| 10 8 | ```elixir |
| @@ -15,9 +13,71 @@ def deps do | |
| 15 13 | end |
| 16 14 | ``` |
| 17 15 | |
| 16 | + ## Usage |
| 17 | + |
| 18 | + ### Setup |
| 19 | + |
| 20 | + - Add a map column to your database table: `add(:avatar, :map)` |
| 21 | + - Add a `Dropkick.File` field to your ecto schema: `field(:avatar, Dropkick.File)` |
| 22 | + |
| 23 | + ### Configuration |
| 24 | + |
| 25 | + Add the following configuration to your `config.exs`: |
| 26 | + |
| 27 | + ```elixir |
| 28 | + config :dropkick, |
| 29 | + repo: MyAppRepo, |
| 30 | + storage: Dropkick.Storage.Disk, |
| 31 | + folder: "uploads" |
| 32 | + ``` |
| 33 | + |
| 34 | + ### Uploader |
| 35 | + |
| 36 | + Define an uplodader for your application: |
| 37 | + |
| 38 | + ```elixir |
| 39 | + defmodule MyApp.Uploader do |
| 40 | + use Dropkick.Uploader |
| 41 | + |
| 42 | + # Defines where to store the user avatar through pattern matching |
| 43 | + def storage_prefix({user, :avatar}), do: "avatars/#{user.id}" |
| 44 | + |
| 45 | + # You can also implement a list of callbacks that allow you to |
| 46 | + # customize what happens in your upload pipeline |
| 47 | + # def process(file, _scope), do: {:ok, file} |
| 48 | + # def before_store(file, _scope), do: {:ok, file} |
| 49 | + # def after_store(file, _scope), do: {:ok, file} |
| 50 | + # def before_delete(file, _scope), do: {:ok, file} |
| 51 | + # def after_delete(file, _scope), do: {:ok, file} |
| 52 | + end |
| 53 | + ``` |
| 54 | + |
| 55 | + ### Save the files |
| 56 | + |
| 57 | + ```elixir |
| 58 | + import Dropkick.Context |
| 59 | + |
| 60 | + def create_user(user, attrs) do |
| 61 | + user |
| 62 | + |> User.changeset(attrs) |
| 63 | + |> insert_with_files(MyApp.Uploader) |
| 64 | + end |
| 65 | + |
| 66 | + def update_user(user, attrs) do |
| 67 | + user |
| 68 | + |> User.changeset(attrs) |
| 69 | + |> update_with_files(MyApp.Uploader) |
| 70 | + end |
| 71 | + ``` |
| 72 | + |
| 18 73 | ## Missing bits |
| 19 74 | |
| 20 | - - Increase test coverage |
| 21 | - - Implement more image transformations |
| 22 | - - Add video transformations |
| 23 | - - Add support to S3 storage |
| \ No newline at end of file | |
| 75 | + - Add integration for file transformations |
| 76 | + - Add integration with [Briefly](https://hexdocs.pm/briefly) to make transformation/ cleanup of temporary files easier. |
| 77 | + - Support other types of storages (S3, Azure, etc) |
| 78 | + - Add strategy to allow cleaning up old files after update |
| 79 | + - Improve documentation and examples for modules and functions |
| 80 | + - Add examples of using libraries for processing files: |
| 81 | + - [`image`](https://hexdocs.pm/image) |
| 82 | + - [`ex_optimizer`](https://hexdocs.pm/ex_optimizer) |
| 83 | + - [`mogrify`](https://hexdocs.pm/mogrify) |
| @@ -5,32 +5,38 @@ | |
| 5 5 | {<<"files">>, |
| 6 6 | [<<"lib">>,<<"lib/dropkick">>,<<"lib/dropkick/storage">>, |
| 7 7 | <<"lib/dropkick/storage/memory.ex">>,<<"lib/dropkick/storage/disk.ex">>, |
| 8 | - <<"lib/dropkick/uploader.ex">>,<<"lib/dropkick/attachment.ex">>, |
| 8 | + <<"lib/dropkick/file.ex">>,<<"lib/dropkick/uploader.ex">>, |
| 9 9 | <<"lib/dropkick/storage.ex">>,<<"lib/dropkick/application.ex">>, |
| 10 | - <<"lib/dropkick/transform.ex">>,<<"lib/dropkick/attachable.ex">>, |
| 11 | - <<"lib/dropkick.ex">>,<<"mix.exs">>,<<"README.md">>]}. |
| 10 | + <<"lib/dropkick/context.ex">>,<<"lib/dropkick/task.ex">>, |
| 11 | + <<"lib/dropkick/changeset.ex">>,<<"lib/dropkick.ex">>,<<"mix.exs">>, |
| 12 | + <<"README.md">>]}. |
| 12 13 | {<<"licenses">>,[<<"AGPL-3.0-only">>]}. |
| 13 14 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/thiagomajesk/dropkick">>}]}. |
| 14 15 | {<<"name">>,<<"dropkick">>}. |
| 15 16 | {<<"requirements">>, |
| 16 | - [[{<<"app">>,<<"jason">>}, |
| 17 | - {<<"name">>,<<"jason">>}, |
| 18 | - {<<"optional">>,false}, |
| 19 | - {<<"repository">>,<<"hexpm">>}, |
| 20 | - {<<"requirement">>,<<"~> 1.0">>}], |
| 21 | - [{<<"app">>,<<"ecto">>}, |
| 17 | + [[{<<"app">>,<<"ecto">>}, |
| 22 18 | {<<"name">>,<<"ecto">>}, |
| 23 19 | {<<"optional">>,false}, |
| 24 20 | {<<"repository">>,<<"hexpm">>}, |
| 25 21 | {<<"requirement">>,<<"~> 3.0">>}], |
| 26 | - [{<<"app">>,<<"plug">>}, |
| 27 | - {<<"name">>,<<"plug">>}, |
| 28 | - {<<"optional">>,false}, |
| 29 | - {<<"repository">>,<<"hexpm">>}, |
| 30 | - {<<"requirement">>,<<"~> 1.0">>}], |
| 31 22 | [{<<"app">>,<<"image">>}, |
| 32 23 | {<<"name">>,<<"image">>}, |
| 33 24 | {<<"optional">>,false}, |
| 34 25 | {<<"repository">>,<<"hexpm">>}, |
| 35 | - {<<"requirement">>,<<"~> 0.30.0">>}]]}. |
| 36 | - {<<"version">>,<<"0.0.0">>}. |
| 26 | + {<<"requirement">>,<<"~> 0.30.0">>}], |
| 27 | + [{<<"app">>,<<"jason">>}, |
| 28 | + {<<"name">>,<<"jason">>}, |
| 29 | + {<<"optional">>,false}, |
| 30 | + {<<"repository">>,<<"hexpm">>}, |
| 31 | + {<<"requirement">>,<<"~> 1.0">>}], |
| 32 | + [{<<"app">>,<<"infer">>}, |
| 33 | + {<<"name">>,<<"infer">>}, |
| 34 | + {<<"optional">>,false}, |
| 35 | + {<<"repository">>,<<"hexpm">>}, |
| 36 | + {<<"requirement">>,<<"~> 0.2.4">>}], |
| 37 | + [{<<"app">>,<<"sizeable">>}, |
| 38 | + {<<"name">>,<<"sizeable">>}, |
| 39 | + {<<"optional">>,false}, |
| 40 | + {<<"repository">>,<<"hexpm">>}, |
| 41 | + {<<"requirement">>,<<"~> 1.0">>}]]}. |
| 42 | + {<<"version">>,<<"0.0.1">>}. |
| @@ -1,119 +1,3 @@ | |
| 1 1 | defmodule Dropkick do |
| 2 | - @moduledoc """ |
| 3 | - This module provides functions that you can use to interact directly with uploads and attachments. |
| 4 | - """ |
| 5 | - |
| 6 | - alias Dropkick.Attachment |
| 7 | - |
| 8 | - @doc """ |
| 9 | - Caches an attachable by saving it on the provided directory under the "cache" prefix by default. |
| 10 | - When an attachable is cached we won't calculate any metadata information, the file is only |
| 11 | - saved to the directory. This function is usually usefull when you are doing async uploads - |
| 12 | - where you first save the file to a temporary location and only after some confirmation you actually move |
| 13 | - the file to its final destination. You probably want to clean this directory from time to time. |
| 14 | - """ |
| 15 | - def cache(attachable, opts \\ []) do |
| 16 | - opts = Keyword.put(opts, :prefix, "cache") |
| 17 | - |
| 18 | - with {:ok, atch} <- put(attachable, opts) do |
| 19 | - {:ok, Map.replace!(atch, :status, :cached)} |
| 20 | - end |
| 21 | - end |
| 22 | - |
| 23 | - @doc """ |
| 24 | - Stores an attachable by saving it on the provided directory under the "store" prefix by default. |
| 25 | - When an attachable is stored we'll calculate metadata information before moving the file to its destination. |
| 26 | - """ |
| 27 | - def store(upload, opts \\ []) do |
| 28 | - opts = Keyword.put(opts, :prefix, "store") |
| 29 | - |
| 30 | - with {:ok, atch} <- put(upload, opts) do |
| 31 | - {:ok, Map.replace!(atch, :status, :stored)} |
| 32 | - end |
| 33 | - end |
| 34 | - |
| 35 | - @doc """ |
| 36 | - Moves an attachble from its current destination to another one. |
| 37 | - This function can be used to "promote" cached attachments without having |
| 38 | - to worry about cleaning up the temporary directory. |
| 39 | - """ |
| 40 | - def move(%Attachment{status: :cached} = atch, opts \\ []) do |
| 41 | - dest = Keyword.fetch!(opts, :dest) |
| 42 | - |
| 43 | - with {:ok, atch} <- copy(atch, dest, move: true) do |
| 44 | - {:ok, Map.replace!(atch, :status, :stored)} |
| 45 | - end |
| 46 | - end |
| 47 | - |
| 48 | - @doc """ |
| 49 | - Creates a version of the attachment with some transformation. |
| 50 | - Transformations validated against an attachment `content_type`. |
| 51 | - The current transformations supported are: |
| 52 | - |
| 53 | - ## `image/*` |
| 54 | - Image transformations uses the [`image`](https://hexdocs.pm/image) library behind the scenes |
| 55 | - |
| 56 | - - `{:thumbnail, size, opts}`: Generates an image thumbnail, receives the same options |
| 57 | - as [`Image.thumbnail/3`](https://hexdocs.pm/image/Image.html#thumbnail/3) |
| 58 | - """ |
| 59 | - def transform(%Attachment{} = atch, transforms) do |
| 60 | - Enum.map(transforms, fn |
| 61 | - {:thumbnail, size, params} -> |
| 62 | - Task.Supervisor.async_nolink(Dropkick.TransformTaskSupervisor, fn -> |
| 63 | - with {:ok, transform} <- Dropkick.Transform.thumbnail(atch, size, params), |
| 64 | - {:ok, version} <- store(transform, folder: Path.dirname(transform.key)) do |
| 65 | - {:ok, Map.update!(atch, :versions, fn versions -> [version | versions] end)} |
| 66 | - end |
| 67 | - end) |
| 68 | - |
| 69 | - transform -> |
| 70 | - raise "Not a valid transform param #{inspect(transform)}" |
| 71 | - end) |
| 72 | - end |
| 73 | - |
| 74 | - def url(%Attachment{} = atch, opts \\ []) do |
| 75 | - version = Keyword.get(opts, :version) |
| 76 | - atch = (version && version(atch, version)) || atch |
| 77 | - Path.join(Keyword.get(opts, :host, "/"), atch.key) |
| 78 | - end |
| 79 | - |
| 80 | - def version(%Attachment{} = atch, version) do |
| 81 | - Enum.find(atch.versions, &(&1.version == version)) |
| 82 | - end |
| 83 | - |
| 84 | - def contextualize(%Attachment{} = atch) do |
| 85 | - key = Dropkick.Attachable.key(atch) |
| 86 | - |
| 87 | - %{ |
| 88 | - filename: Path.basename(key), |
| 89 | - extension: Path.extname(key), |
| 90 | - directory: Path.dirname(key) |
| 91 | - } |
| 92 | - end |
| 93 | - |
| 94 | - @doc """ |
| 95 | - Calls the underlyning storage's `put` function. |
| 96 | - Check the module `Dropkick.Storage` for documentation about the available options. |
| 97 | - """ |
| 98 | - def put(upload, opts \\ []), do: storage!().put(upload, opts) |
| 99 | - |
| 100 | - @doc """ |
| 101 | - Calls the underlyning storage's `read` function. |
| 102 | - Check the module `Dropkick.Storage` for documentation about the available options. |
| 103 | - """ |
| 104 | - def read(upload, opts \\ []), do: storage!().read(upload, opts) |
| 105 | - |
| 106 | - @doc """ |
| 107 | - Calls the underlyning storage's `copy` function. |
| 108 | - Check the module `Dropkick.Storage` for documentation about the available options. |
| 109 | - """ |
| 110 | - def copy(upload, dest, opts \\ []), do: storage!().copy(upload, dest, opts) |
| 111 | - |
| 112 | - @doc """ |
| 113 | - Calls the underlyning storage's `delete` function. |
| 114 | - Check the module `Dropkick.Storage` for documentation about the available options. |
| 115 | - """ |
| 116 | - def delete(upload, opts \\ []), do: storage!().delete(upload, opts) |
| 117 | - |
| 118 | - defp storage!(), do: Application.fetch_env!(:dropkick, :storage) |
| 2 | + @moduledoc File.read!("README.md") |
| 119 3 | end |
| @@ -1,88 +0,0 @@ | |
| 1 | - defprotocol Dropkick.Attachable do |
| 2 | - @spec key(struct() | binary()) :: String.t() |
| 3 | - def key(upload) |
| 4 | - |
| 5 | - @spec name(struct() | binary()) :: String.t() |
| 6 | - def name(upload) |
| 7 | - |
| 8 | - @spec content(struct() | binary()) :: {:ok, iodata()} | {:error, String.t()} |
| 9 | - def content(upload) |
| 10 | - end |
| 11 | - |
| 12 | - defimpl Dropkick.Attachable, for: BitString do |
| 13 | - def key(path), do: path |
| 14 | - |
| 15 | - def name(path), do: Path.basename(path) |
| 16 | - |
| 17 | - def content(path) do |
| 18 | - if Regex.match?(~r"^http(s?)\:\/\/.+", path) do |
| 19 | - Dropkick.Attachable.content(URI.new!(path)) |
| 20 | - else |
| 21 | - case File.read(Path.expand(path)) do |
| 22 | - {:error, reason} -> {:error, "Could not read path: #{reason}"} |
| 23 | - success_tuple -> success_tuple |
| 24 | - end |
| 25 | - end |
| 26 | - end |
| 27 | - end |
| 28 | - |
| 29 | - defimpl Dropkick.Attachable, for: Plug.Upload do |
| 30 | - def key(%Plug.Upload{path: path}), do: path |
| 31 | - |
| 32 | - def name(%Plug.Upload{filename: name}), do: name |
| 33 | - |
| 34 | - def content(%Plug.Upload{path: path}) do |
| 35 | - case File.read(path) do |
| 36 | - {:error, reason} -> {:error, "Could not read path: #{reason}"} |
| 37 | - success_tuple -> success_tuple |
| 38 | - end |
| 39 | - end |
| 40 | - end |
| 41 | - |
| 42 | - defimpl Dropkick.Attachable, for: URI do |
| 43 | - def key(%URI{path: path}), do: path |
| 44 | - |
| 45 | - def name(%URI{path: path}), do: Path.basename(path) |
| 46 | - |
| 47 | - def content(%URI{} = uri) do |
| 48 | - uri = String.to_charlist(URI.to_string(uri)) |
| 49 | - |
| 50 | - case :httpc.request(:get, {uri, []}, [], body_format: :binary) do |
| 51 | - {:ok, {{'HTTP/1.1', 200, 'OK'}, _headers, content}} -> |
| 52 | - {:ok, content} |
| 53 | - |
| 54 | - {:ok, {{'HTTP/1.1', code, _}, _headers, _}} -> |
| 55 | - {:error, "Unsuccessful response code: #{code}"} |
| 56 | - |
| 57 | - {:error, {reason, _}} -> |
| 58 | - {:error, "Could not read path: #{reason}"} |
| 59 | - end |
| 60 | - end |
| 61 | - end |
| 62 | - |
| 63 | - defimpl Dropkick.Attachable, for: Dropkick.Attachment do |
| 64 | - def key(%{key: key}), do: key |
| 65 | - |
| 66 | - def name(%{filename: name}), do: name |
| 67 | - |
| 68 | - def content(attachment) do |
| 69 | - case attachment do |
| 70 | - %{storage: nil} -> |
| 71 | - raise "No storage defined for this attachment" |
| 72 | - |
| 73 | - %{storage: storage} when is_atom(storage) -> |
| 74 | - storage.read(attachment) |
| 75 | - |
| 76 | - %{storage: storage} -> |
| 77 | - raise("#{inspect(storage)} is not a module") |
| 78 | - end |
| 79 | - end |
| 80 | - end |
| 81 | - |
| 82 | - defimpl Dropkick.Attachable, for: Dropkick.Transform do |
| 83 | - def key(%{key: key}), do: key |
| 84 | - |
| 85 | - def name(%{filename: name}), do: name |
| 86 | - |
| 87 | - def content(%{content: content}), do: content |
| 88 | - end |
| @@ -1,29 +0,0 @@ | |
| 1 | - defmodule Dropkick.Attachment do |
| 2 | - @derive {Jason.Encoder, only: [:key]} |
| 3 | - @enforce_keys [:key, :storage, :filename] |
| 4 | - defstruct [:key, :status, :storage, :filename, :content_type, :metadata, versions: []] |
| 5 | - |
| 6 | - use Ecto.Type |
| 7 | - |
| 8 | - def type, do: :map |
| 9 | - |
| 10 | - def cast(%__MODULE__{} = atch), do: {:ok, atch} |
| 11 | - def cast(atch) when is_map(atch), do: {:ok, struct(__MODULE__, atch)} |
| 12 | - def cast(_), do: :error |
| 13 | - |
| 14 | - def load(data) when is_map(data) do |
| 15 | - data = |
| 16 | - Enum.map(data, fn |
| 17 | - {"versions", v} -> {:versions, Enum.map(v, &load/1)} |
| 18 | - {"status", v} -> {:status, String.to_existing_atom(v)} |
| 19 | - {"storage", v} -> {:storage, String.to_existing_atom(v)} |
| 20 | - {k, v} -> {String.to_existing_atom(k), v} |
| 21 | - end) |
| 22 | - |
| 23 | - {:ok, struct!(__MODULE__, data)} |
| 24 | - end |
| 25 | - |
| 26 | - def dump(%__MODULE__{} = atch), do: {:ok, Map.from_struct(atch)} |
| 27 | - def dump(data) when is_map(data), do: {:ok, data} |
| 28 | - def dump(_), do: :error |
| 29 | - end |
Loading more files…