diff --git a/README.md b/README.md index 095678a..9b67699 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # Dropkick -Dropkick is a highly experimental library that provides easy to use uploads for the Elixir/ Phoenix ecosystem. +Dropkick is a highly experimental library that provides easy to use uploads for the Elixir/ Phoenix ecosystem. This is a opinionated library focused on developer ergonomics that you can use to provide file uploads in any Phoenix project. -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/). - ## Installation ```elixir @@ -19,83 +17,77 @@ end ### Setup -- Add a map column to your database: `add(:avatar, :map)` -- Add a field to your schema: `field(:avatar, Dropkick.Attachment)` +- Add a map column to your database table: `add(:avatar, :map)` +- Add a `Dropkick.File` field to your ecto schema: `field(:avatar, Dropkick.File)` -### Basic uploader example +### Configuration -You can setup a very basic uploader like this: +Add the following configuration to your `config.exs`: ```elixir -defmodule FileUploader do - use Dropkick.Uploader - - @impl true - def validate(_attachable, %{action: :store}), do: :ok -end +config :dropkick, + repo: MyAppRepo, + storage: Dropkick.Storage.Disk, + folder: "uploads" ``` +<<<<<<< HEAD +### Uploader + +Define an uplodader for your application: +======= After that, you simply cast the type like you would normally do: +>>>>>>> master ```elixir -def changeset(user, attrs) do - user - |> cast(attrs, [:avatar]) - |> validate_required([:avatar]) - |> prepare_changes(&store_attachments(&1, [:avatar])) -end +defmodule MyApp.Uploader do + use Dropkick.Uploader -# You can add a little helper function to properly store the attachment -# and process everything only when the changeset is actually valid. -defp store_attachments(changeset, fields) do - Enum.reduce(fields, changeset, fn field, chset -> - if attachment = Ecto.Changeset.get_change(changeset, field) do - case FileUploader.store(attachment) do - {:ok, atch} -> Ecto.Changeset.put_change(chset, field, atch) - {:error, reason} -> Ecto.Changeset.add_error(chset, field, to_string(reason)) - end - end - end) -end + # Defines where to store the user avatar through pattern matching + def storage_prefix({user, :avatar}), do: "avatars/#{user.id}" + + # You can also implement a list of callbacks that allow you to + # customize what happens in your upload pipeline + # def process(file, _scope), do: {:ok, file} + # def before_store(file, _scope), do: {:ok, file} + # def after_store(file, _scope), do: {:ok, file} + # def before_delete(file, _scope), do: {:ok, file} + # def after_delete(file, _scope), do: {:ok, file} +end ``` -### Async uploader - -You can also do async uploads by doing some modifications in the upload workflow. First you want to have an endpoint that saves (or caches) files when a user interacts with a file uploader on the frontend. When an upload arrives at this endpoint you want to call the function `FileUploader.cache(upload)` to save the file into a temporary folder (this folder should ideally be cleaned from time to time). +### Save the files ```elixir -defmodule FileUploader do - use Dropkick.Uploader +import Dropkick.Context - # Skip validation when storing the file - def validate(_attachable, %{action: :store}), do: :ok - - # Only validates the file when caching, since we are doing async uploads - def validate(%{filename: filename}, %{action: :cache}) do - extension = Path.extname(filename) - - case Enum.member?(~w(.jpg .jpeg .gif .png), extension) do - true -> :ok - false -> {:error, "invalid file type"} - end - end - - # You can change how the files will be saved - def storage_prefix(_attachable, scope) do - %{year: year, month: month, day: day} = DateTime.utc_now() - # The current action is automatically added to the scope, but you can - # call the cache and store functions with custom scopes to customize this even further. - "#{to_string(scope.action)}/#{year}/#{month}/#{day}" - end +def create_user(user, attrs) do + user + |> User.changeset(attrs) + |> insert_with_files(MyApp.Uploader) end -``` -If you are using forms to submit the final file, you'll likely want to return the cached file path or an identifier to the frontend so you can retrieve the file in the next post (you can save this identifier into a hidden field for instance). And then, after you finish doing your schema validations you can simply call `FileUploader.store` to store the file into its final location. - -> If you don't want to expose the file path, you can use the function `Dropkick.Security.sign(attachment)` to generate a token that you can send to clients. This might be desirable if you are uploading files to disk as it prevents a malicius user from tampering with the final file location. +def update_user(user, attrs) do + user + |> User.changeset(attrs) + |> update_with_files(MyApp.Uploader) +end +``` ## Missing bits +<<<<<<< HEAD +- Add integration for file transformations +- Add integration with [Briefly](https://hexdocs.pm/briefly) to make transformation/ cleanup of temporary files easier. +- Support other types of storages (S3, Azure, etc) +- Add strategy to allow cleaning up old files after update +- Improve documentation and examples for modules and functions +- Add examples of using libraries for processing files: + - [`image`](https://hexdocs.pm/image) + - [`ex_optimizer`](https://hexdocs.pm/ex_optimizer) + - [`mogrify`](https://hexdocs.pm/mogrify) +======= - Implement more image transformations - Add video transformations - Add support to S3 storage +>>>>>>> master diff --git a/config/config.exs b/config/config.exs index 0cbbca5..871a3d1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,7 +1,3 @@ import Config -config :dropkick, - storage: Dropkick.Storage.Memory, - secret_key_base: Base.encode64(String.duplicate("x", 12)) - import_config "#{Mix.env()}.exs" diff --git a/config/test.exs b/config/test.exs index b4c5ab9..d6d7e13 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,6 @@ import Config -config :dropkick, storage: Dropkick.Storage.Disk +config :dropkick, + repo: TestRepo, + storage: Dropkick.Storage.Disk, + folder: "uploads" diff --git a/lib/dropkick.ex b/lib/dropkick.ex index 41aa15a..b8cb9c7 100644 --- a/lib/dropkick.ex +++ b/lib/dropkick.ex @@ -1,106 +1,3 @@ defmodule Dropkick do - @moduledoc """ - This module provides functions that you can use to interact directly with uploads and attachments. - """ - - alias Dropkick.Attachment - - @doc """ - Creates a version of the attachment with some transformation. - Transformations validated against an attachment `content_type`. - The current transformations supported are: - - ## `image/*` - Image transformations uses the [`image`](https://hexdocs.pm/image) library behind the scenes - - - `{:thumbnail, size, opts}`: Generates an image thumbnail, receives the same options - as [`Image.thumbnail/3`](https://hexdocs.pm/image/Image.html#thumbnail/3) - """ - def transform(%Attachment{} = atch, transforms) do - atch - |> transform_stream(transforms) - |> Stream.filter(&match?({:ok, _}, &1)) - |> Enum.reduce(atch, fn {:ok, version}, atch -> - Map.update!(atch, :versions, fn versions -> [version | versions] end) - end) - end - - @doc """ - Extracts context from the attachment. - """ - def contextualize(%Attachment{} = atch) do - key = Dropkick.Attachable.key(atch) - - %{ - extension: Path.extname(key), - directory: Path.dirname(key), - filename: Path.basename(key) - } - end - - @doc """ - Extracts metadata from the attachment. - """ - def extract_metadata(%Attachment{content_type: "image/" <> _} = atch) do - # If our attachment is an image, we try to extract additional information. - # Depending on the complexity we should probably move this into a 'Metadata' module in the future. - case Dropkick.Attachable.content(atch) do - {:ok, content} -> - {mimetype, width, height, variant} = ExImageInfo.info(content) - - %{ - mimetype: mimetype, - dimension: "#{width}x#{height}", - variant: variant - } - - _ -> - %{} - end - end - - # If we don't yet support extracting metadata from the content type we do nothing. - # In the future this could be expanded to other formats as long as we have a proper lib in the ecosystem do do that. - def extract_metadata(%Attachment{} = atch), do: atch - - @doc """ - Calls the underlyning storage's `put` function. - Check the module `Dropkick.Storage` for documentation about the available options. - """ - def put(attachable, opts \\ []), - do: Dropkick.Storage.current().put(attachable, opts) - - @doc """ - Calls the underlyning storage's `read` function. - Check the module `Dropkick.Storage` for documentation about the available options. - """ - def read(attachable, opts \\ []), - do: Dropkick.Storage.current().read(attachable, opts) - - @doc """ - Calls the underlyning storage's `copy` function. - Check the module `Dropkick.Storage` for documentation about the available options. - """ - def copy(attachable, dest, opts \\ []), - do: Dropkick.Storage.current().copy(attachable, dest, opts) - - @doc """ - Calls the underlyning storage's `delete` function. - Check the module `Dropkick.Storage` for documentation about the available options. - """ - def delete(attachable, opts \\ []), - do: Dropkick.Storage.current().delete(attachable, opts) - - defp transform_stream(atch, transforms) do - Task.Supervisor.async_stream_nolink(Dropkick.TransformTaskSupervisor, transforms, fn - {:thumbnail, size, params} -> - with {:ok, transform} <- Dropkick.Transform.thumbnail(atch, size, params), - {:ok, version} <- put(transform, folder: Path.dirname(transform.key)) do - version - end - - transform -> - raise "Not a valid transform param #{inspect(transform)}" - end) - end + @moduledoc File.read!("README.md") end diff --git a/lib/dropkick/attachable.ex b/lib/dropkick/attachable.ex deleted file mode 100644 index e7e8289..0000000 --- a/lib/dropkick/attachable.ex +++ /dev/null @@ -1,102 +0,0 @@ -defprotocol Dropkick.Attachable do - @spec key(struct() | binary()) :: String.t() - def key(attachable) - - @spec name(struct() | binary()) :: String.t() - def name(attachable) - - @spec content(struct() | binary()) :: {:ok, binary()} | {:error, String.t()} - def content(attachable) - - @spec type(struct() | binary()) :: binary() - def type(attachable) -end - -defimpl Dropkick.Attachable, for: BitString do - def key(path), do: path - - def name(path), do: Path.basename(path) - - def content(path) do - if Regex.match?(~r"^http(s?)\:\/\/.+", path) do - Dropkick.Attachable.content(URI.new!(path)) - else - case File.read(Path.expand(path)) do - {:error, reason} -> {:error, "Could not read path: #{reason}"} - success_tuple -> success_tuple - end - end - end - - def type(path), do: MIME.from_path(path) -end - -defimpl Dropkick.Attachable, for: Plug.Upload do - def key(%Plug.Upload{path: path}), do: path - - def name(%Plug.Upload{filename: name}), do: name - - def content(%Plug.Upload{path: path}) do - case File.read(path) do - {:error, reason} -> {:error, "Could not read path: #{reason}"} - success_tuple -> success_tuple - end - end - - def type(%Plug.Upload{content_type: type}), do: type -end - -defimpl Dropkick.Attachable, for: URI do - def key(%URI{path: path}), do: path - - def name(%URI{path: path}), do: Path.basename(path) - - def content(%URI{} = uri) do - uri = String.to_charlist(URI.to_string(uri)) - - case :httpc.request(:get, {uri, []}, [], body_format: :binary) do - {:ok, {{'HTTP/1.1', 200, 'OK'}, _headers, content}} -> - {:ok, content} - - {:ok, {{'HTTP/1.1', code, _}, _headers, _}} -> - {:error, "Unsuccessful response code: #{code}"} - - {:error, {reason, _}} -> - {:error, "Could not read path: #{reason}"} - end - end - - def type(%URI{path: path}), do: MIME.from_path(path) -end - -defimpl Dropkick.Attachable, for: Dropkick.Attachment do - def key(%{key: key}), do: key - - def name(%{filename: name}), do: name - - def content(attachment) do - case attachment do - %{storage: nil} -> - raise "No storage defined for this attachment" - - %{storage: storage} when is_atom(storage) -> - storage.read(attachment) - - %{storage: storage} -> - raise("#{inspect(storage)} is not a module") - end - end - - def type(%{key: key, content_type: nil}), do: MIME.from_path(key) - def type(%{content_type: type}), do: type -end - -defimpl Dropkick.Attachable, for: Dropkick.Transform do - def key(%{key: key}), do: key - - def name(%{filename: name}), do: name - - def content(%{content: content}), do: content - - def type(%{key: key}), do: MIME.from_path(key) -end diff --git a/lib/dropkick/attachment.ex b/lib/dropkick/attachment.ex deleted file mode 100644 index 1bc9382..0000000 --- a/lib/dropkick/attachment.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule Dropkick.Attachment do - @moduledoc """ - Represents an attachment that can be saved to a database - """ - @derive Jason.Encoder - @enforce_keys [:key, :storage] - defstruct [:key, :storage, :filename, :content_type, :metadata, versions: []] - - use Ecto.Type - - def type, do: :map - - def cast(token) when is_binary(token) do - case Dropkick.Security.check(token) do - {:ok, key} -> - name = Dropkick.Attachable.name(key) - type = Dropkick.Attachable.type(key) - storage = Dropkick.Storage.current() - - data = %{ - key: key, - filename: name, - content_type: type, - storage: storage - } - - {:ok, struct!(__MODULE__, data)} - - {:error, reason} -> - {:error, "Invalid token #{reason}"} - end - end - - def cast(%__MODULE__{} = atch), do: {:ok, atch} - def cast(atch) when is_map(atch), do: {:ok, struct(__MODULE__, atch)} - def cast(_), do: :error - - def load(data) when is_map(data) do - data = - Enum.map(data, fn - {"versions", v} -> - {:versions, Enum.map(v, &load_version/1)} - - {"storage", v} -> - {:storage, String.to_atom(v)} - - {k, v} -> - {String.to_existing_atom(k), v} - end) - - {:ok, struct!(__MODULE__, data)} - end - - def dump(%__MODULE__{} = atch), do: {:ok, Map.from_struct(atch)} - def dump(data) when is_map(data), do: {:ok, data} - def dump(_), do: :error - - defp load_version(version) do - case load(version) do - {:ok, data} -> data - {:error, reason} -> raise "Failed to load version #{reason}" - end - end -end diff --git a/lib/dropkick/changeset.ex b/lib/dropkick/changeset.ex new file mode 100644 index 0000000..6020ec9 --- /dev/null +++ b/lib/dropkick/changeset.ex @@ -0,0 +1,69 @@ +defmodule Dropkick.Changeset do + @doc """ + Validates that the given field of type `Dropkick.File` has the allowed extensions. + This function should be considered unsafe unless you define your file fields with `infer: true`. + + ## Example + + validate_file_extension(changeset, :avatar, ~w(png jpg)) + """ + def validate_file_extension(%Ecto.Changeset{} = changeset, field, extensions) do + Ecto.Changeset.validate_change(changeset, field, fn field, file -> + <> = Path.extname(file.filename) + message = "Only the following extensions are allowed #{Enum.join(extensions, ",")}" + if extension not in extensions, do: [{field, message}], else: [] + end) + end + + @doc """ + Validates that the given field of type `Dropkick.File` has the allowed content types. + This function should be considered unsafe unless you define your file fields with `infer: true`. + + ## Example + + validate_file_type(changeset, :avatar, ~w(image/jpeg image/jpeg)) + + """ + def validate_file_type(%Ecto.Changeset{} = changeset, field, content_types) do + Ecto.Changeset.validate_change(changeset, field, fn field, file -> + message = "Only the following content types are allowed #{inspect(content_types)}" + if file.content_type not in content_types, do: [{field, message}], else: [] + end) + end + + @doc """ + Validates that the given field of type `Dropkick.File` has the allowed size in bytes. + + ## Options + + - `:is`: The file size must be exactly this value. + - `:max`: The file size must be less than this value. + - `:min`: The file size must be greater than this value. + + ## Example + + validate_file_size(changeset, :avatar, max: 10 * 1024 * 1024) + """ + def validate_file_size(changeset, field, opts) do + Ecto.Changeset.validate_change(changeset, field, fn field, file -> + %{size: size} = File.stat!(file.key) + + cond do + opts[:is] != nil && size != opts[:is] -> + size = Sizeable.filesize(opts[:is]) + [{field, "The file should have exactly #{size}"}] + + opts[:max] && size > opts[:max] -> + size = Sizeable.filesize(opts[:max]) + [{field, "The file size should be no more than #{size}"}] + + opts[:min] && size < opts[:min] -> + size = Sizeable.filesize(opts[:min]) + [{field, "The file size should be no less than #{size}"}] + + true -> + [] + end + end) + end +end diff --git a/lib/dropkick/context.ex b/lib/dropkick/context.ex new file mode 100644 index 0000000..2212246 --- /dev/null +++ b/lib/dropkick/context.ex @@ -0,0 +1,153 @@ +defmodule Dropkick.Context do + @doc """ + Inserts a changeset with its associated files. + This function relies on `Ecto.Multi` to persist the attached information in the same transaction + and it accepts the same options as `Ecto.Repo.insert/2`. + + ## Examples + + def create_user(user, attrs) do + user + |> User.changeset(attrs) + |> insert_with_files(MyApp.Uploader) + end + + """ + def insert_with_files(%Ecto.Changeset{} = changeset, uploader, opts \\ []) do + repo = Application.fetch_env!(:dropkick, :repo) + %{data: %{__struct__: module}} = changeset + + fields = + Enum.filter(module.__schema__(:fields), fn field -> + Ecto.Changeset.changed?(changeset, field) && + match?( + {:parameterized, Dropkick.File, %{field: ^field}}, + module.__schema__(:type, field) + ) + end) + + files_multi = + Enum.reduce(fields, Ecto.Multi.new(), fn field, multi -> + Ecto.Multi.run(multi, {:file, field}, fn repo, %{insert: schema} -> + with {:ok, file} <- uploader.store(Map.get(schema, field), {schema, field}) do + repo.update(Ecto.Changeset.change(schema, %{field => file})) + end + end) + end) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:insert, changeset, opts) + |> Ecto.Multi.append(files_multi) + |> repo.transaction() + |> case do + {:ok, %{insert: schema}} -> + {:ok, schema} + + {:error, :insert, changeset, _} -> + {:error, changeset} + + {:error, {:file, field}, reason, %{insert: _schema}} -> + {:error, Ecto.Changeset.add_error(changeset, field, inspect(reason))} + end + end + + @doc """ + Updates a changeset with its associated files. + This function relies on `Ecto.Multi` to persist the attached information in the same transaction + and it accepts the same options as `Ecto.Repo.update/2`. + + ## Examples + + def update_user(user, attrs) do + user + |> User.changeset(attrs) + |> update_with_files(MyApp.Uploader) + end + + """ + def update_with_files(%Ecto.Changeset{} = changeset, uploader, opts \\ []) do + repo = Application.fetch_env!(:dropkick, :repo) + %{data: %{__struct__: module}} = changeset + + fields = + Enum.filter(module.__schema__(:fields), fn field -> + Ecto.Changeset.changed?(changeset, field) && + match?( + {:parameterized, Dropkick.File, %{field: ^field}}, + module.__schema__(:type, field) + ) + end) + + files_multi = + Enum.reduce(fields, Ecto.Multi.new(), fn field, multi -> + Ecto.Multi.run(multi, {:file, field}, fn repo, %{update: schema} -> + with {:ok, file} <- uploader.store(Map.get(schema, field), {schema, field}) do + # TODO: Add strategy to delete old files instead of keeping them + repo.update(Ecto.Changeset.change(schema, %{field => file})) + end + end) + end) + + Ecto.Multi.new() + |> Ecto.Multi.update(:update, changeset, opts) + |> Ecto.Multi.append(files_multi) + |> repo.transaction() + |> case do + {:ok, %{update: schema}} -> + {:ok, schema} + + {:error, :update, changeset, _} -> + {:error, changeset} + + {:error, {:file, field}, reason, %{update: _schema}} -> + {:error, Ecto.Changeset.add_error(changeset, field, inspect(reason))} + end + end + + @doc """ + Deletes a changeset with its associated files. + This function relies on `Ecto.Multi` to persist the attached information in the same transaction + and it accepts the same options as `Ecto.Repo.update/2`. + + ## Examples + + def delete_user(user, attrs) do + user + |> User.changeset(attrs) + |> update_with_files(MyApp.Uploader) + end + + """ + def delete_with_files(%Ecto.Changeset{} = changeset, uploader, opts \\ []) do + repo = Application.fetch_env!(:dropkick, :repo) + %{data: %{__struct__: module}} = changeset + + fields = + Enum.filter(module.__schema__(:fields), fn field -> + match?({:parameterized, Dropkick.File, %{field: ^field}}, module.__schema__(:type, field)) + end) + + files_multi = + Enum.reduce(fields, Ecto.Multi.new(), fn field, multi -> + Ecto.Multi.run(multi, {:file, field}, fn _repo, %{delete: schema} -> + # TODO: Add strategy to keep old files instead of deleting them + uploader.delete(Map.get(schema, field), {schema, field}) + end) + end) + + Ecto.Multi.new() + |> Ecto.Multi.delete(:delete, changeset, opts) + |> Ecto.Multi.append(files_multi) + |> repo.transaction() + |> case do + {:ok, %{delete: schema}} -> + {:ok, schema} + + {:error, :delete, changeset, _} -> + {:error, changeset} + + {:error, {:file, field}, reason, %{delete: _schema}} -> + {:error, Ecto.Changeset.add_error(changeset, field, inspect(reason))} + end + end +end diff --git a/lib/dropkick/file.ex b/lib/dropkick/file.ex new file mode 100644 index 0000000..4e20f84 --- /dev/null +++ b/lib/dropkick/file.ex @@ -0,0 +1,107 @@ +defmodule Dropkick.File do + @moduledoc """ + A custom type that maps a upload-like structure to a file. + + * `:key` - The location of the the uploaded file + * `:content_type` - The content type of the uploaded file + * `:filename` - The filename of the uploaded file given in the request + * `:status` - The status of the uploaded file. It can be `:cached` (when the file + was just casted from a `Plug.Upload` and the key points to a temporary directory), `:deleted` (when + the file was deleted from the storage and the key points to its old location) and `:stored` (when the + file was persisted to its final destination and the key points to its current location). + * `:metadata` - This field is meant to be used by users to store metadata about the file. + * `:__cache__` - This field is used to store internal in-memmory information about the file that might + be relevant during processing (this field is currently not used internally and its not casted to the database). + + ## Security + + Like `Plug.Upload`, the `:content_type` and `:filename` fields are client-controlled. + Because of this, you should inspect and validate these values before trusting the upload content. + You can check the file's [magic number](https://en.wikipedia.org/wiki/Magic_number_(programming)) signature + by passing the option `infer: true` to your fields: + + field :avatar, Dropkick.File, infer: true + """ + @derive {Inspect, optional: [:metadata], except: [:__cache__]} + @derive {Jason.Encoder, except: [:__cache__]} + @enforce_keys [:key, :status, :filename, :content_type] + defstruct [:__cache__, :key, :status, :filename, :content_type, :metadata] + + use Ecto.ParameterizedType + + @impl true + def type(_params), do: :map + + @impl true + def init(opts), do: Enum.into(opts, %{}) + + @impl true + def cast(nil, _params), do: {:ok, nil} + + def cast(%__MODULE__{} = file, _params), do: {:ok, file} + + def cast(%{filename: filename, path: path, content_type: content_type}, params) do + case Map.get(params, :infer, false) && Infer.get_from_path(path) do + nil -> + {:error, "File might be invalid. Could not infer file type information"} + + false -> + {:ok, + %__MODULE__{ + key: path, + status: :cached, + filename: filename, + content_type: content_type + }} + + %{extension: ext, mime_type: type} -> + filename = Path.basename(filename, Path.extname(filename)) + + {:ok, + %__MODULE__{ + key: path, + status: :cached, + filename: "#{filename}.#{ext}", + content_type: type + }} + end + end + + def cast(_data, _params), do: :error + + @impl true + def load(nil, _loader, _params), do: {:ok, nil} + + def load(data, _loader, _params) when is_map(data) do + data = + Enum.map(data, fn + {"status", v} -> + {:status, String.to_atom(v)} + + {k, v} -> + {String.to_existing_atom(k), v} + end) + + {:ok, struct!(__MODULE__, data)} + end + + @impl true + def dump(nil, _dumper, _params), do: {:ok, nil} + def dump(%__MODULE__{} = file, _dumper, _params), do: {:ok, from_map(file)} + def dump(data, _dumper, _params) when is_map(data), do: {:ok, from_map(data)} + def dump(_data, _dumper, _params), do: :error + + @impl true + def equal?(%{key: key1}, %{key: key2}, _params), do: key1 == key2 + def equal?(val1, val2, _params), do: val1 == val2 + + defp from_map(map) do + Map.take(map, [ + :key, + :status, + :filename, + :content_type, + :metadata + ]) + end +end diff --git a/lib/dropkick/security.ex b/lib/dropkick/security.ex deleted file mode 100644 index 01c4efc..0000000 --- a/lib/dropkick/security.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Dropkick.Security do - @moduledoc false - alias Dropkick.Attachment - - @key_base Application.compile_env!(:dropkick, :secret_key_base) - - def sign(%Attachment{key: key}) do - Plug.Crypto.encrypt(@key_base, to_string(__MODULE__), key, max_age: 3600) - end - - def check(token) when is_binary(token) do - Plug.Crypto.decrypt(@key_base, to_string(__MODULE__), token) - end -end diff --git a/lib/dropkick/storage.ex b/lib/dropkick/storage.ex index 19ec193..2f5dbdc 100644 --- a/lib/dropkick/storage.ex +++ b/lib/dropkick/storage.ex @@ -1,52 +1,38 @@ defmodule Dropkick.Storage do - alias Dropkick.{Attachable, Attachment} - - @type option :: {atom(), any()} - @doc """ - Stores the given attachable with the underlyning storage module. - + Stores the given file with the underlyning storage module. The underlyning implementation should accept the following options (besides any specific options): - - `:folder`: The base location where to save the file being transfered. - - `:prefix`: A sub-folder inside the current location where the file is going to be saved, defaults to `/`. + * `:folder`: The base location where to save the file being transfered. + * `:prefix`: A sub-folder inside the current location where the file is going to be saved, defaults to `/`. - Returns a success tuple like `{:ok, %Attachment{status: :stored}}`. + Returns a success tuple like: `{:ok, %Dropkick.File{}}`. """ - @callback put(Attachable.t(), [option]) :: {:ok, Attachment.t()} | {:error, String.t()} + @callback store(Dropkick.File.t(), Keyword.t()) :: + {:ok, Dropkick.File.t()} | {:error, String.t()} @doc """ - Reads the given attachment with the underlyning storage module. - When the attachment storage is set to `Disk`, we always attempt to read the file from its key. - Otherwise, we resort to the definition of `Attachable.content/1` so we can also read remote files if necessary. - + Reads the given file with the underlyning storage module. Returns a success tuple with the content of the file like: `{:ok, content}`. """ - @callback read(Attachment.t(), [option]) :: {:ok, binary()} | {:error, String.t()} + @callback read(Dropkick.File.t(), Keyword.t()) :: + {:ok, binary()} | {:error, String.t()} @doc """ - Copies the given attachment with the underlyning storage module. - When the attachment storage is set to `Disk`, we always attempt to read the file from its key. - + Copies the given file with the underlyning storage module. The underlyning implementation should accept the following options (besides any specific options): - - `:move`: Specifies if the file should be just copied or moved entirely, defaults to `false`. - - Returns a success tuple with the attachment in the new destination like: `{:ok, %Attachment{}}`. - """ - @callback copy(Attachment.t(), String.t(), [option]) :: - {:ok, Attachment.t()} | {:error, String.t()} + * `:move`: Specifies if the file should be just copied or moved entirely, defaults to `false`. - @doc """ - Deletes the given attachment with the underlyning storage module. - Diferently from the `read` and `copy` actions that takes into consideration the current attachment's storage. - This function assumes the attachment being deleted uses the configured storage. So, if your current attachment - is configured as `Disk` and you try to delete an attachment which storage is setup as `Memory`, the call will fail. + Returns a success tuple with the file in the new destination like: `{:ok, %Dropkick.File{}}`. """ - @callback delete(Attachment.t(), [option]) :: :ok | {:error, String.t()} + @callback copy(Dropkick.File.t(), String.t(), Keyword.t()) :: + {:ok, Dropkick.File.t()} | {:error, String.t()} @doc """ - Returns the current configured storage. + Deletes the given file with the underlyning storage module. + Returns a success tuple with the deleted the file like: `{:ok, %Dropkick.File{}}`. """ - def current, do: Application.fetch_env!(:dropkick, :storage) + @callback delete(Dropkick.File.t(), Keyword.t()) :: + {:ok, Dropkick.File.t()} | {:error, String.t()} end diff --git a/lib/dropkick/storage/disk.ex b/lib/dropkick/storage/disk.ex index c82b312..6e74ebd 100644 --- a/lib/dropkick/storage/disk.ex +++ b/lib/dropkick/storage/disk.ex @@ -1,80 +1,45 @@ defmodule Dropkick.Storage.Disk do - alias Dropkick.{Storage, Attachable, Attachment} + @behaviour Dropkick.Storage - @behaviour Storage + @impl true + def store(%Dropkick.File{status: :cached} = file, opts \\ []) do + folder = Keyword.fetch!(opts, :folder) + prefix = Keyword.fetch!(opts, :prefix) - @impl Storage - def put(attachable, opts \\ []) do - folder = Keyword.get(opts, :folder, "uploads") - prefix = Keyword.get(opts, :prefix, "/") + key = Path.join([folder, prefix, file.filename]) - name = Attachable.name(attachable) - path = Path.join([folder, prefix, name]) - - with :ok <- File.mkdir_p(Path.dirname(path)), - {:ok, content} <- Attachable.content(attachable) do - type = Attachable.type(attachable) - File.write!(path, content) - - {:ok, - %Attachment{ - key: path, - filename: name, - storage: __MODULE__, - content_type: type - }} - end - end - - @impl Storage - def read(atch, opts \\ []) - - @impl Storage - def read(%Attachment{storage: Dropkick.Storage.Disk} = atch, _opts) do - case File.read(Attachable.key(atch)) do - {:error, reason} -> {:error, "Could not read file: #{reason}"} - success_result -> success_result + with :ok <- File.mkdir_p(Path.dirname(key)), + {:ok, content} <- File.read(file.key), + File.write(key, content) do + {:ok, Map.merge(file, %{key: key, status: :stored})} end end - @impl Storage - def read(%Attachment{} = atch, _opts) do - case Attachable.content(atch) do + @impl true + def read(%Dropkick.File{} = file, _opts \\ []) do + case File.read(file.key) do {:error, reason} -> {:error, "Could not read file: #{reason}"} success_result -> success_result end end - @impl Storage - def copy(atch, path, opts \\ []) - - @impl Storage - def copy(%Attachment{storage: Dropkick.Storage.Disk} = atch, path, opts) do + @impl true + def copy(%Dropkick.File{} = file, dest, opts \\ []) do move? = Keyword.get(opts, :move, false) - with :ok <- File.mkdir_p(Path.dirname(path)), - :ok <- move_or_rename(Attachable.key(atch), path, move?) do - {:ok, Map.replace!(atch, :key, path)} + with :ok <- File.mkdir_p(Path.dirname(dest)), + :ok <- move_or_rename(file.key, dest, move?) do + {:ok, Map.replace!(file, :key, dest)} else {:error, reason} -> {:error, "Could not copy file: #{reason}"} end end - @impl Storage - def copy(%Attachment{} = atch, dest, _opts) do - with {:ok, content} <- Attachable.content(atch), - :ok <- File.write(Attachable.key(atch), content) do - {:ok, Map.replace!(atch, :key, dest)} - else - {:error, reason} -> {:error, "Could not copy file: #{reason}"} - end - end - - @impl Storage - def delete(%Attachment{} = atch, _opts \\ []) do - case File.rm(Attachable.key(atch)) do + @impl true + def delete(%Dropkick.File{} = file, _opts \\ []) do + case File.rm(file.key) do + :ok -> {:ok, Map.replace!(file, :status, :deleted)} {:error, reason} -> {:error, "Could not delete file: #{reason}"} - success_result -> success_result end end diff --git a/lib/dropkick/storage/memory.ex b/lib/dropkick/storage/memory.ex index 2594cb1..e01a9ac 100644 --- a/lib/dropkick/storage/memory.ex +++ b/lib/dropkick/storage/memory.ex @@ -1,43 +1,37 @@ defmodule Dropkick.Storage.Memory do - alias Dropkick.{Storage, Attachable, Attachment} + # A note about converting list to pids and vice-versa... + # This BIF is intended for debugging and is not to be used in application programs. + # This storage strategy should be used for tests only: https://www.erlang.org/doc/man/erlang.html#list_to_pid-1 + @behaviour Dropkick.Storage - @behaviour Storage - - @impl Storage - def put(upload, _opts \\ []) do - with {:ok, content} <- Attachable.content(upload), + @impl true + def store(%Dropkick.File{status: :cached} = file, _opts \\ []) do + with {:ok, content} <- File.read(file.key), {:ok, pid} <- StringIO.open(content) do - type = Attachable.type(upload) - name = Attachable.name(upload) - key = encode_key(pid, name) - - {:ok, - %Attachment{ - key: key, - filename: name, - storage: __MODULE__, - content_type: type - }} + key = encode_key(pid, file.filename) + {:ok, Map.merge(file, %{key: key, status: :stored})} end end - @impl Storage - def read(%Attachment{} = atch, _opts \\ []) do - {:ok, read_from_memory(atch)} + @impl true + def read(%Dropkick.File{} = file, _opts \\ []) do + {:ok, read_from_memory(file)} end - @impl Storage - def copy(%Attachment{} = atch, path, _opts \\ []) do - with content <- read_from_memory(atch), + @impl true + def copy(%Dropkick.File{} = file, dest, _opts \\ []) do + with content <- read_from_memory(file), {:ok, pid} <- StringIO.open(content) do - {:ok, Map.replace!(atch, :key, encode_key(pid, path))} + {:ok, Map.replace!(file, :key, encode_key(pid, dest))} end end - @impl Storage - def delete(%Attachment{} = atch, _opts \\ []) do - pid = decode_key(Attachable.key(atch)) - with {:ok, _} <- StringIO.close(pid), do: :ok + @impl true + def delete(%Dropkick.File{} = file, _opts \\ []) do + with pid <- decode_key(file.key), + {:ok, _} <- StringIO.close(pid) do + {:ok, Map.replace!(file, :status, :deleted)} + end end @doc false @@ -53,8 +47,6 @@ defmodule Dropkick.Storage.Memory do @doc false def decode_key(key) when is_binary(key) do - # https://www.erlang.org/doc/man/erlang.html#list_to_pid-1 - # This BIF is intended for debugging and is not to be used in application programs. <<"mem://", encoded::binary-size(12), ?/, _rest::binary>> = key encoded @@ -63,9 +55,8 @@ defmodule Dropkick.Storage.Memory do |> :erlang.list_to_pid() end - defp read_from_memory(atch) do - atch - |> Attachable.key() + defp read_from_memory(%{key: key}) do + key |> decode_key() |> StringIO.contents() |> then(&elem(&1, 0)) diff --git a/lib/dropkick/task.ex b/lib/dropkick/task.ex new file mode 100644 index 0000000..8d520a9 --- /dev/null +++ b/lib/dropkick/task.ex @@ -0,0 +1,10 @@ +defmodule Dropkick.Task do + @doc """ + Pipes the `value` to the given `fun` and returns the `value` itself. + The function runs asynchronously in a unliked process for safe side effects. + """ + def tap_async(value, fun) do + sup = Dropkick.TransformTaskSupervisor + Task.Supervisor.async_nolink(sup, fun.(value)) && value + end +end diff --git a/lib/dropkick/transform.ex b/lib/dropkick/transform.ex deleted file mode 100644 index 8f4d977..0000000 --- a/lib/dropkick/transform.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Dropkick.Transform do - @moduledoc false - defstruct [:key, :filename, :content] - - alias __MODULE__ - - def thumbnail(atch, size, opts) do - with {:ok, content} <- Dropkick.Attachable.content(atch), - {:ok, image} <- Image.from_binary(content), - {:ok, image} <- Image.thumbnail(image, size, opts) do - content = Vix.Vips.Image.write_to_binary(image) - - directory = Path.dirname(Dropkick.Attachable.key(atch)) - key = Path.join([directory, "thumbnail", size, atch.filename]) - - {:ok, - %Transform{ - content: content, - filename: atch.filename, - key: key - }} - end - end -end diff --git a/lib/dropkick/uploader.ex b/lib/dropkick/uploader.ex index b32fd7f..3af240c 100644 --- a/lib/dropkick/uploader.ex +++ b/lib/dropkick/uploader.ex @@ -1,49 +1,57 @@ defmodule Dropkick.Uploader do @moduledoc """ - This modules provides a definition to specialized uploaders that can be used to specify custom upload workflows. - A specialized uploader acts as a hook before calling the function at `Dropkick` and allow you to modify how the upload is handled. + Defines the behaviour for a file uploader. """ - alias Dropkick.{Attachable, Attachment} - @type option :: {atom(), any()} + @doc """ + Returns the storage path for a given scope. + This function can be used to personalize the directory where files are saved. - @type transforms :: - :noaction - | {:blur, list()} - | {:resize, float(), list()} - | {:thumbnail, pos_integer() | String.t(), list()} + ## Example - @doc """ - Caches an attachable by saving it on the provided directory under the "cache" prefix by default. - When an attachable is cached we won't calculate any metadata information, the file is only - saved to the directory. This function is usually usefull when you are doing async uploads - - where you first save the file to a temporary location and only after some confirmation you actually move - the file to its final destination. You probably want to clean this directory from time to time. - """ + You can user pattern matching to specify multiple clauses: - @callback cache({Attachable.t(), map()}, [option]) :: {:ok, Attachment.type()} + def storage_prefix({store, :logo}), do: "avatars/\#{user.id}" + def storage_prefix({user, :avatar}), do: "avatars/\#{user.id}" + def storage_prefix({%{id: id}, _}), do: "files/\#{id}" + """ + @callback storage_prefix(any()) :: String.t() @doc """ - Stores an attachable by saving it on the provided directory under the "store" prefix by default. - When an attachable is stored we'll calculate metadata information before moving the file to its destination. + Process some logic before storing a `%Dropkick.File` struct. + This function must return a success tuple with the file, otherwise the store operation will fail. + When an error is returned, the store operation won't be executed and the pipeline is aborted. """ + @callback before_store(Dropkick.File.t(), any()) :: {:ok, Dropkick.File.t()} - @callback store({Attachable.t(), map()}, [option]) :: {:ok, Attachment.type()} + @doc """ + Process some logic after storing a `%Dropkick.File` struct. + This function must return a success tuple with the file, otherwise the operation will fail. + When an error is returned, the file is still stored, but the rest of the pipeline is aborted. + """ + @callback after_store(Dropkick.File.t(), any()) :: {:ok, Dropkick.File.t()} @doc """ - Defines a series of validations that will be called before caching the attachment. + Process some logic before deleting a `%Dropkick.File` struct. + This function must return a success tuple with the file, otherwise the operation will fail. + When an error is returned, the delete operation won't be executed and the pipeline is aborted. """ - @callback validate(Attachable.t(), map()) :: :ok | {:error, String.t()} + @callback before_delete(Dropkick.File.t(), any()) :: {:ok, Dropkick.File.t()} @doc """ - Defines the transformations to be applied after saving attachments. + Process some logic after deleting a `%Dropkick.File` struct. + This function must return a success tuple with the file, otherwise the operation will fail. + When an error is returned, the file is still deleted, but the rest of the pipeline is aborted. """ - @callback transform(Attachment.type(), map()) :: transforms() + @callback after_delete(Dropkick.File.t(), any()) :: {:ok, Dropkick.File.t()} @doc """ - Defines the default prefix that will be used to store and retrieve uploads. + Process some logic after storing/deleting a `%Dropkick.File` struct. + When a success tuple is returned, the value is simply logged into the terminal. + Whatever is executed inside this callback is completely isolated and doesn't affect the pipeline. + You can implement this callback to do any post-processing with without modifying the original file. """ - @callback storage_prefix(Attachable.t(), map()) :: String.t() + @callback process(Dropkick.File.t(), any()) :: {:ok, Dropkick.File.t()} defmacro __using__(_opts) do quote do @@ -51,100 +59,58 @@ defmodule Dropkick.Uploader do require Logger - def cache(struct_or_tuple, opts \\ []) - - def cache(attachable, opts) when is_struct(attachable), - do: cache({attachable, %{}}, opts) - - def cache({attachable, scope}, opts) when is_map(scope) do - scope = Map.put(scope, :action, :cache) - - folder = Keyword.get(opts, :folder, "uploads") - prefix = __MODULE__.storage_prefix(attachable, scope) - - cache_opts = [folder: folder, prefix: prefix] - - with :ok <- __MODULE__.validate(attachable, scope), - {:ok, atch} <- Dropkick.put(attachable, cache_opts) do - metadata = - merge_metadata([ - Dropkick.contextualize(atch), - Dropkick.extract_metadata(atch) - ]) - - atch = Map.put(atch, :metadata, metadata) - transforms = list_transforms(atch, scope) - {:ok, Dropkick.transform(atch, transforms)} + @doc """ + Stores the given `%Dropkick.File` struct. + """ + def store(%Dropkick.File{status: :cached} = file, scope) do + storage = Application.fetch_env!(:dropkick, :storage) + folder = Application.fetch_env!(:dropkick, :folder) + prefix = __MODULE__.storage_prefix(scope) + + with {:ok, file} <- __MODULE__.before_store(file, scope), + {:ok, file} <- storage.store(file, folder: folder, prefix: prefix), + {:ok, file} <- __MODULE__.after_store(file, scope) do + Dropkick.Task.tap_async({:ok, file}, fn {:ok, file} -> + with {:ok, file} <- __MODULE__.process(file, scope) do + Logger.info("Finished storing file #{inspect(file)}") + end + end) end end - def store(struct_or_tuple, opts \\ []) - - def store(attachable, opts) when is_struct(attachable), - do: store({attachable, %{}}, opts) - - def store({attachable, scope}, opts) when is_map(scope) do - scope = Map.put(scope, :action, :store) - - folder = Keyword.get(opts, :folder, "uploads") - prefix = __MODULE__.storage_prefix(attachable, scope) - - store_opts = [folder: folder, prefix: prefix] - - with :ok <- __MODULE__.validate(attachable, scope), - {:ok, atch} <- Dropkick.put(attachable, store_opts) do - metadata = - merge_metadata([ - Dropkick.contextualize(atch), - Dropkick.extract_metadata(atch) - ]) - - atch = Map.put(atch, :metadata, metadata) - transforms = list_transforms(atch, scope) - {:ok, Dropkick.transform(atch, transforms)} + @doc """ + Deletes the given `%Dropkick.File` struct. + """ + def delete(%Dropkick.File{} = file, scope) do + storage = Application.fetch_env!(:dropkick, :storage) + + with {:ok, file} <- __MODULE__.before_delete(file, scope), + {:ok, file} <- storage.delete(file), + {:ok, file} <- __MODULE__.after_delete(file, scope) do + Dropkick.Task.tap_async({:ok, file}, fn {:ok, file} -> + with {:ok, result} <- __MODULE__.process(file, scope) do + Logger.info("Finished deleting file #{inspect(result)}") + end + end) end end - def url(%Attachment{key: key}) do - Path.join(Application.get_env(:dropkick, :host, "/"), key) - end + def storage_prefix(_scope), do: "" - def validate(_attachable, _scope) do - raise "Function validate/2 not implemented for #{__MODULE__}" - end + def before_store(file, _scope), do: {:ok, file} + def after_store(file, _scope), do: {:ok, file} - def storage_prefix(_attachable, %{action: action}), do: to_string(action) + def before_delete(file, _scope), do: {:ok, file} + def after_delete(file, _scope), do: {:ok, file} - def transform(%Attachment{content_type: "image/" <> _}, %{action: :store}) do - {:thumbnail, "250x250", [crop: :center]} - end - - # If user has implemented this callback but forgot to deal with the proper action - # We don't want to automatically transform attachments without letting them know. - def transform(_atch, %{action: :cache}) do - message = """ - It seems that you have accidentaly enabled the transformation of cached attachments... - This could mean that you forgot to handle other actions in your `transform/2` callback implementation. - Don't worry though, we are automatically ignoring transformations for cached attachments for you. - If this is not desirable, please implement a version of `transform/2` that handles the `cache` action. - """ - - with :ok <- Logger.warn(message), do: :noaction - end - - defp list_transforms(atch, scope) do - atch - |> __MODULE__.transform(scope) - |> List.wrap() - |> List.flatten() - |> Enum.reject(&(&1 == :noaction)) - end - - defp merge_metadata(maps) do - Enum.reduce(maps, %{}, &Map.merge(&2, &1)) - end + def process(file, _scope), do: {:ok, file} - defoverridable validate: 2, storage_prefix: 2, transform: 2 + defoverridable storage_prefix: 1, + before_store: 2, + after_store: 2, + before_delete: 2, + after_delete: 2, + process: 2 end end end diff --git a/mix.exs b/mix.exs index 0577b98..0fb0eca 100644 --- a/mix.exs +++ b/mix.exs @@ -67,14 +67,15 @@ defmodule Dropkick.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:jason, "~> 1.0"}, {:ecto, "~> 3.0"}, - {:plug, "~> 1.0"}, - {:exvcr, "~> 0.13.5", only: :test}, {:image, "~> 0.30.0"}, + {:jason, "~> 1.0"}, + {:infer, "~> 0.2.4"}, + {:sizeable, "~> 1.0"}, {:ex_doc, "~> 0.29", only: :dev, runtime: false}, - {:mime, "~> 2.0"}, - {:ex_image_info, "~> 0.2.4"} + {:exvcr, "~> 0.13.5", only: :test}, + {:ecto_sql, ">= 3.0.0", only: :test}, + {:postgrex, ">= 0.0.0", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 8ff18d5..a11cb8d 100644 --- a/mix.lock +++ b/mix.lock @@ -2,9 +2,11 @@ "castore": {:hex, :castore, "1.0.1", "240b9edb4e9e94f8f56ab39d8d2d0a57f49e46c56aced8f873892df8ff64ff5a", [:mix], [], "hexpm", "b4951de93c224d44fac71614beabd88b71932d0b1dea80d2f80fb9044e01bbb3"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.7", "77de20ac77f0e53f20ca82c563520af0237c301a1ec3ab3bc598e8a96c7ee5d9", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2768b28bf3c2b4f788c995576b39b8cb5d47eb788526d93bd52206c1d8bf4b75"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, "ecto": {:hex, :ecto, "3.10.1", "c6757101880e90acc6125b095853176a02da8f1afe056f91f1f90b80c9389822", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2ac4255f1601bdf7ac74c0ed971102c6829dc158719b94bd30041bbad77f87a"}, + "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, "elixir_make": {:hex, :elixir_make, "0.7.6", "67716309dc5d43e16b5abbd00c01b8df6a0c2ab54a8f595468035a50189f9169", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5a0569756b0f7873a77687800c164cca6dfc03a09418e6fcf853d78991f49940"}, "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, "ex_image_info": {:hex, :ex_image_info, "0.2.4", "610002acba43520a9b1cf1421d55812bde5b8a8aeaf1fe7b1f8823e84e762adb", [:mix], [], "hexpm", "fd1a7e02664e3b14dfd3b231d22fdd48bd3dd694c4773e6272b3a6228f1106bc"}, @@ -12,11 +14,15 @@ "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, "exvcr": {:hex, :exvcr, "0.13.5", "3cb058c3a360bd0ff45d5e9f061723fad25e3a56157b76c0bbf20e8990470b14", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "89a7b951dd93ef735fbf2c83a552c962f2bdc9e1954fe355ac95c3128f285e8c"}, + "gradient": {:git, "https://github.com/esl/gradient.git", "3a795ed8b2949b9fb5d3826b57bba948ed0cf049", []}, + "gradient_macros": {:git, "https://github.com/esl/gradient_macros.git", "3bce2146bf0cdf380f773c40e2b7bd6558ab6de8", [ref: "3bce214"]}, + "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "1498d1792155010950c86dc3e92ccb111b706e80", [ref: "1498d17"]}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "httpoison": {:hex, :httpoison, "2.1.0", "655fd9a7b0b95ee3e9a3b535cf7ac8e08ef5229bab187fa86ac4208b122d934b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fc455cb4306b43827def4f57299b2d5ac8ac331cb23f517e734a4b78210a160c"}, "ibrowse": {:hex, :ibrowse, "4.4.0", "2d923325efe0d2cb09b9c6a047b2835a5eda69d8a47ed6ff8bc03628b764e991", [:rebar3], [], "hexpm", "6a8e5988872086f0506bef68311493551ac5beae7c06ba2a00d5e9f97a60f1c2"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "image": {:hex, :image, "0.30.0", "7b8eb202b7ece5f1fc38494a0943e0cd57e1dabfd31995ee292ed9cc4a52c28d", [:mix], [{:bumblebee, "~> 0.2", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.26", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.5", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14 or ~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.17", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "a12e8b707ef8ad7caf1326c857c41ceb1fa47e395eef1b1a118f5a56cb5d70e6"}, + "infer": {:hex, :infer, "0.2.4", "828ecc2f42c0bfc754361e2b68517fb9b236feba7d83f9633d9097522a46ad4f", [:mix], [], "hexpm", "13b9678a8704c75aa1a052f2dcc71985c2145ec48c46529aa783468811d7dd70"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, @@ -31,6 +37,8 @@ "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"}, + "sizeable": {:hex, :sizeable, "1.0.2", "625fe06a5dad188b52121a140286f1a6ae1adf350a942cf419499ecd8a11ee29", [:mix], [], "hexpm", "4bab548e6dfba777b400ca50830a9e3a4128e73df77ab1582540cf5860601762"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, diff --git a/test/attachable_test.exs b/test/attachable_test.exs deleted file mode 100644 index e08658d..0000000 --- a/test/attachable_test.exs +++ /dev/null @@ -1,56 +0,0 @@ -defmodule AttachableTest do - use ExUnit.Case, async: true - use ExVCR.Mock, adapter: ExVCR.Adapter.Httpc - use Dropkick.FileCase - - describe "attachable protocol" do - test "works with binary", %{path: path} do - assert Dropkick.Attachable.name("foo") == "foo" - assert Dropkick.Attachable.name("foo/bar") == "bar" - assert Dropkick.Attachable.name("foo/bar.jpg") == "bar.jpg" - assert Dropkick.Attachable.name(path) == Path.basename(path) - - assert {:error, "Could not read path: enoent"} = Dropkick.Attachable.content("foo") - assert {:error, "Could not read path: enoent"} = Dropkick.Attachable.content("foo/bar") - assert {:error, "Could not read path: enoent"} = Dropkick.Attachable.content("foo/bar.jpg") - assert {:ok, "Hello World"} = Dropkick.Attachable.content(path) - end - - @tag :nofile - test "works with binary that looks like a URL" do - ExVCR.Config.cassette_library_dir("test/fixtures/vcr_cassettes") - uri = "https://octodex.github.com/images/dojocat.jpg" - - use_cassette "github_octodex_dojocat" do - assert "dojocat.jpg" = Dropkick.Attachable.name(uri) - # Asserts that we are actually dealing with the correct file format (JPG) - assert {:ok, <<0xFF, 0xD8, _rest::binary>>} = Dropkick.Attachable.content(uri) - end - end - - test "works with upload struct", %{path: path} do - upload = %Plug.Upload{ - path: path, - filename: Path.basename(path), - content_type: "image/jpg" - } - - assert Dropkick.Attachable.name(upload) == Path.basename(path) - assert {:ok, "Hello World"} = Dropkick.Attachable.content(upload) - end - - @tag :nofile - test "works with URI" do - ExVCR.Config.cassette_library_dir("test/fixtures/vcr_cassettes") - uri = URI.new!("https://octodex.github.com/images/dojocat.jpg") - - :inets.start() - - use_cassette "github_octodex_dojocat" do - assert "dojocat.jpg" = Dropkick.Attachable.name(uri) - # Ensure we are dealing with a JPG - assert {:ok, <<0xFF, 0xD8, _rest::binary>>} = Dropkick.Attachable.content(uri) - end - end - end -end diff --git a/test/attachment_test.exs b/test/attachment_test.exs deleted file mode 100644 index f18bcbe..0000000 --- a/test/attachment_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule AttachmentTest do - use ExUnit.Case - use Dropkick.FileCase - - alias Dropkick.Attachment - - test "cast attachment" do - atch = %Attachment{key: "/", filename: "", content_type: "", storage: ""} - assert {:ok, %Attachment{}} = Attachment.cast(atch) - end - - test "cast map" do - atch = %{key: "/", filename: "", content_type: "", storage: ""} - assert {:ok, %Attachment{}} = Attachment.cast(atch) - end - - test "encrypted token" do - atch = %Attachment{key: "/foo.jpg", filename: "", content_type: "", storage: ""} - token = Dropkick.Security.sign(atch) - assert {:ok, %{key: "/foo.jpg"}} = Attachment.cast(token) - end - - test "load map" do - atch = %{ - "key" => "/", - "filename" => "", - "content_type" => "", - "storage" => "Dropkick.Storage.Memory" - } - - assert {:ok, %Attachment{}} = Attachment.load(atch) - end - - test "dump attachment" do - atch = %Attachment{key: "/", filename: "", content_type: "", storage: ""} - assert {:ok, %{key: "/", filename: "", content_type: "", storage: ""}} = Attachment.dump(atch) - end - - test "dump map" do - atch = %{key: "/", filename: "", content_type: "", storage: ""} - assert {:ok, %{key: "/", filename: "", content_type: "", storage: ""}} = Attachment.dump(atch) - end -end diff --git a/test/changeset_test.exs b/test/changeset_test.exs new file mode 100644 index 0000000..ab855ee --- /dev/null +++ b/test/changeset_test.exs @@ -0,0 +1,74 @@ +defmodule ChangesetTest do + use ExUnit.Case, async: true + use Dropkick.FileCase + + setup %{path: path} do + {:ok, + upload: %{ + path: path, + filename: Path.basename(path), + content_type: "image/jpg" + }} + end + + @tag filename: "foo.png" + test "validate_file_extension/2", %{upload: upload} do + changeset = Ecto.Changeset.cast(%TestUser{}, %{name: "foo", avatar: upload}, [:avatar]) + + assert %Ecto.Changeset{valid?: true} = + Dropkick.Changeset.validate_file_extension( + changeset, + :avatar, + ~w(png) + ) + + assert %Ecto.Changeset{valid?: false} = + Dropkick.Changeset.validate_file_extension( + changeset, + :avatar, + ~w(gif) + ) + end + + @tag filename: "foo.png" + test "validate_file_type/2", %{upload: upload} do + changeset = Ecto.Changeset.cast(%TestUser{}, %{name: "foo", avatar: upload}, [:avatar]) + + assert %Ecto.Changeset{valid?: true} = + Dropkick.Changeset.validate_file_type( + changeset, + :avatar, + ~w(image/jpg) + ) + + assert %Ecto.Changeset{valid?: false} = + Dropkick.Changeset.validate_file_type( + changeset, + :avatar, + ~w(image/gif) + ) + end + + @tag filename: "foo.gif" + test "validate_file_size/2", %{upload: upload} do + changeset = Ecto.Changeset.cast(%TestUser{}, %{name: "foo", avatar: upload}, [:avatar]) + + assert %Ecto.Changeset{valid?: true} = + Dropkick.Changeset.validate_file_size(changeset, :avatar, is: 11) + + assert %Ecto.Changeset{valid?: true} = + Dropkick.Changeset.validate_file_size(changeset, :avatar, min: 11) + + assert %Ecto.Changeset{valid?: true} = + Dropkick.Changeset.validate_file_size(changeset, :avatar, max: 11) + + assert %Ecto.Changeset{valid?: false} = + Dropkick.Changeset.validate_file_size(changeset, :avatar, is: 10) + + assert %Ecto.Changeset{valid?: false} = + Dropkick.Changeset.validate_file_size(changeset, :avatar, min: 12) + + assert %Ecto.Changeset{valid?: false} = + Dropkick.Changeset.validate_file_size(changeset, :avatar, max: 10) + end +end diff --git a/test/context_test.exs b/test/context_test.exs new file mode 100644 index 0000000..3c70146 --- /dev/null +++ b/test/context_test.exs @@ -0,0 +1,52 @@ +defmodule ContextTest do + use ExUnit.Case, async: true + use Dropkick.FileCase + + defmodule TestUploader do + use Dropkick.Uploader + end + + setup %{path: path} do + on_exit(fn -> File.rm_rf!("uploads") end) + + {:ok, + upload: %{ + path: path, + filename: Path.basename(path), + content_type: "image/jpg" + }} + end + + test "insert_with_files", %{upload: upload} do + changeset = Ecto.Changeset.cast(%TestUser{}, %{name: "foo", avatar: upload}, [:name, :avatar]) + + assert {:ok, %TestUser{avatar: avatar}} = + Dropkick.Context.insert_with_files(changeset, TestUploader) + + assert File.exists?(avatar.key) + end + + test "update_with_files", %{upload: upload} do + changeset = Ecto.Changeset.cast(%TestUser{}, %{name: "foo"}, [:name]) + {:ok, inserted_test_user} = Dropkick.Context.insert_with_files(changeset, TestUploader) + + changeset = Ecto.Changeset.cast(inserted_test_user, %{avatar: upload}, [:avatar]) + + assert {:ok, %TestUser{avatar: avatar}} = + Dropkick.Context.update_with_files(changeset, TestUploader) + + assert File.exists?(avatar.key) + end + + test "delete_with_files", %{upload: upload} do + changeset = Ecto.Changeset.cast(%TestUser{}, %{name: "foo", avatar: upload}, [:name, :avatar]) + {:ok, inserted_test_user} = Dropkick.Context.insert_with_files(changeset, TestUploader) + + changeset = Ecto.Changeset.cast(inserted_test_user, %{avatar: upload}, [:avatar]) + + assert {:ok, %TestUser{avatar: avatar}} = + Dropkick.Context.delete_with_files(changeset, TestUploader) + + refute File.exists?(avatar.key) + end +end diff --git a/test/dropkick_test.exs b/test/dropkick_test.exs deleted file mode 100644 index 85ee888..0000000 --- a/test/dropkick_test.exs +++ /dev/null @@ -1,44 +0,0 @@ -defmodule DropkickTest do - use ExUnit.Case - use Dropkick.FileCase - - alias Dropkick.Attachment - - describe "transforms" do - @tag copy: "test/fixtures/images/puppies.jpg" - test "thumbnail", %{path: path} do - atch = %Attachment{ - key: path, - filename: Path.basename(path), - content_type: "image/jpg", - storage: Dropkick.Storage.Disk - } - - transforms = [{:thumbnail, "50x50", [crop: :center]}] - %Attachment{versions: [%{key: key}]} = Dropkick.transform(atch, transforms) - assert File.exists?(key) - end - - @tag copy: "test/fixtures/images/puppies.jpg" - test "version is also a valid attachment", %{path: path} do - atch = %Attachment{ - key: path, - filename: Path.basename(path), - content_type: "image/jpg", - storage: Dropkick.Storage.Disk - } - - transforms = [{:thumbnail, "50x50", [crop: :center]}] - %Attachment{versions: [version]} = Dropkick.transform(atch, transforms) - - assert %Attachment{ - key: key, - storage: Dropkick.Storage.Disk, - filename: "puppies.jpg", - content_type: "image/jpeg" - } = version - - assert File.exists?(key) - end - end -end diff --git a/test/file_test.exs b/test/file_test.exs new file mode 100644 index 0000000..fd69d76 --- /dev/null +++ b/test/file_test.exs @@ -0,0 +1,128 @@ +defmodule FileTest do + use ExUnit.Case + use Dropkick.FileCase + + test "cast file", %{path: path} do + file = %Dropkick.File{ + key: path, + filename: Path.basename(path), + content_type: "image/jpg", + status: :cached + } + + assert {:ok, ^file} = Dropkick.File.cast(file, %{}) + end + + test "cast map", %{path: path} do + name = Path.basename(path) + + map = %{ + path: path, + filename: name, + content_type: "image/jpg" + } + + assert {:ok, + %Dropkick.File{ + key: ^path, + filename: ^name, + content_type: "image/jpg", + status: :cached + }} = Dropkick.File.cast(map, %{}) + end + + test "load map", %{path: path} do + name = Path.basename(path) + + map = %{ + "key" => path, + "filename" => name, + "content_type" => "image/jpg", + "status" => "stored" + } + + assert {:ok, + %Dropkick.File{ + key: ^path, + filename: ^name, + content_type: "image/jpg", + status: :stored + }} = Dropkick.File.load(map, nil, %{}) + end + + test "dump file", %{path: path} do + name = Path.basename(path) + + file = %Dropkick.File{ + key: path, + filename: name, + content_type: "image/jpg", + status: :stored + } + + assert {:ok, + %{ + key: ^path, + filename: ^name, + content_type: "image/jpg", + status: :stored + }} = Dropkick.File.dump(file, nil, %{}) + end + + test "dump map", %{path: path} do + name = Path.basename(path) + + map = %{ + key: path, + filename: name, + content_type: "image/jpg", + status: :stored + } + + assert {:ok, + %{ + key: ^path, + filename: ^name, + content_type: "image/jpg", + status: :stored + }} = Dropkick.File.dump(map, nil, %{}) + end + + @tag copy: "test/fixtures/images/puppies.jpg" + test "infer file with correct info", %{path: path} do + name = Path.basename(path) + + map = %{ + path: path, + filename: name, + content_type: "image/jpeg" + } + + assert {:ok, + %Dropkick.File{ + key: ^path, + filename: ^name, + content_type: "image/jpeg", + status: :cached + }} = Dropkick.File.cast(map, %{infer: true}) + end + + @tag copy: "test/fixtures/images/puppies.jpg" + test "infer file with wrong info", %{path: path} do + name = Path.basename(path) + + map = %{ + path: path, + filename: "puppies.gif", + content_type: "image/gif" + } + + assert {:ok, + %Dropkick.File{ + key: ^path, + filename: ^name, + content_type: "image/jpeg", + status: :cached + }} = Dropkick.File.cast(map, %{infer: true}) + end +end diff --git a/test/security_test.exs b/test/security_test.exs deleted file mode 100644 index 77f022a..0000000 --- a/test/security_test.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule SecurityTest do - use ExUnit.Case - use Dropkick.FileCase - - alias Dropkick.Attachment - - test "sign/check" do - atch = %Attachment{key: "/", filename: "", content_type: "", storage: Dropkick.Storage.Memory} - token = Dropkick.Security.sign(atch) - assert {:ok, "/"} = Dropkick.Security.check(token) - end -end diff --git a/test/storage_test.exs b/test/storage_test.exs index 32a979c..f45f509 100644 --- a/test/storage_test.exs +++ b/test/storage_test.exs @@ -2,11 +2,9 @@ defmodule StorageTest do use ExUnit.Case, async: true use Dropkick.FileCase - alias Dropkick.{Storage, Attachment} - setup %{path: path} do {:ok, - upload: %Plug.Upload{ + upload: %{ path: path, filename: Path.basename(path), content_type: "image/jpg" @@ -14,53 +12,78 @@ defmodule StorageTest do end describe "disk storage" do - test "put action", %{dir: dir, upload: upload} do - assert {:ok, %Attachment{key: key}} = Storage.Disk.put(upload, folder: dir) + test "store action", %{dir: dir, upload: upload} do + {:ok, file} = Dropkick.File.cast(upload, %{}) + + assert {:ok, %Dropkick.File{key: key, status: :stored}} = + Dropkick.Storage.Disk.store(file, folder: dir, prefix: "") + assert File.exists?(key) end test "read action", %{dir: dir, upload: upload} do - assert {:ok, atch} = Storage.Disk.put(upload, folder: dir) - assert {:ok, "Hello World"} = Storage.Disk.read(atch) + {:ok, file} = Dropkick.File.cast(upload, %{}) + {:ok, file} = Dropkick.Storage.Disk.store(file, folder: dir, prefix: "") + + assert {:ok, "Hello World"} = Dropkick.Storage.Disk.read(file) end test "copy action", %{dir: dir, upload: upload} do - copy_path = Path.join(dir, "new.jpg") - assert {:ok, atch} = Storage.Disk.put(upload, folder: dir) - assert {:ok, %Attachment{key: ^copy_path}} = Storage.Disk.copy(atch, copy_path) - assert File.exists?(copy_path) + dest = Path.join([dir, "copied", "new.jpg"]) + + {:ok, file} = Dropkick.File.cast(upload, %{}) + {:ok, file} = Dropkick.Storage.Disk.store(file, folder: dir, prefix: "") + + assert {:ok, %Dropkick.File{key: ^dest}} = Dropkick.Storage.Disk.copy(file, dest) + + assert File.exists?(dest) end test "delete action", %{dir: dir, upload: upload} do - assert {:ok, atch} = Storage.Disk.put(upload, folder: dir) - assert :ok = Storage.Disk.delete(atch) - refute File.exists?(atch.key) + {:ok, file} = Dropkick.File.cast(upload, %{}) + {:ok, file} = Dropkick.Storage.Disk.store(file, folder: dir, prefix: "") + + assert {:ok, %Dropkick.File{key: key, status: :deleted}} = + Dropkick.Storage.Disk.delete(file) + + refute File.exists?(key) end end describe "memory storage" do - test "put action", %{upload: upload} do - assert {:ok, %Attachment{key: key}} = Storage.Memory.put(upload) - assert Process.alive?(Storage.Memory.decode_key(key)) + test "store action", %{upload: upload} do + {:ok, file} = Dropkick.File.cast(upload, %{}) + + assert {:ok, %Dropkick.File{key: key, status: :stored}} = + Dropkick.Storage.Memory.store(file) + + assert Process.alive?(Dropkick.Storage.Memory.decode_key(key)) end test "read action", %{upload: upload} do - assert {:ok, atch} = Storage.Memory.put(upload) - assert {:ok, "Hello World"} = Storage.Memory.read(atch) + {:ok, file} = Dropkick.File.cast(upload, %{}) + {:ok, file} = Dropkick.Storage.Memory.store(file) + + assert {:ok, "Hello World"} = Dropkick.Storage.Memory.read(file) end test "copy action", %{dir: dir, upload: upload} do - copy_path = Path.join(dir, "new.jpg") - assert {:ok, atch} = Storage.Memory.put(upload) - assert {:ok, %Attachment{key: key}} = Storage.Memory.copy(atch, copy_path) - assert Process.alive?(Storage.Memory.decode_key(key)) + dest = Path.join([dir, "copied", "new.jpg"]) + {:ok, file} = Dropkick.File.cast(upload, %{}) + {:ok, file} = Dropkick.Storage.Memory.store(file) + + assert {:ok, %Dropkick.File{key: key}} = Dropkick.Storage.Memory.copy(file, dest) + assert Process.alive?(Dropkick.Storage.Memory.decode_key(key)) end test "delete action", %{upload: upload} do - assert {:ok, atch} = Storage.Memory.put(upload) - assert Process.alive?(Storage.Memory.decode_key(atch.key)) - assert :ok = Storage.Memory.delete(atch) - refute Process.alive?(Storage.Memory.decode_key(atch.key)) + {:ok, file} = Dropkick.File.cast(upload, %{}) + {:ok, file} = Dropkick.Storage.Memory.store(file) + + assert {:ok, %Dropkick.File{key: key, status: :deleted}} = + Dropkick.Storage.Memory.delete(file) + + refute Process.alive?(Dropkick.Storage.Memory.decode_key(key)) end end end diff --git a/test/support/file_case.ex b/test/support/file_case.ex index 5281487..8d4827a 100644 --- a/test/support/file_case.ex +++ b/test/support/file_case.ex @@ -12,6 +12,8 @@ defmodule Dropkick.FileCase do using do quote do + # Prevent unecessary loggin on tests + @moduletag :capture_log # Set module attribute to retrieve value from helpers # and proxy value to tags so we can properly cleanup after all tests @moduletag case_id: System.unique_integer([:positive]) @@ -25,7 +27,7 @@ defmodule Dropkick.FileCase do filename = Map.get_lazy(tags, :filename, fn -> file_id = System.unique_integer() - Base.encode32("#{file_id}.jpg", padding: false) + Base.encode32(to_string(file_id), padding: false) <> ".jpg" end) tags = diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..70cc35a 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,41 @@ ExUnit.start() + +defmodule TestRepo do + use Ecto.Repo, otp_app: :dropkick, adapter: Ecto.Adapters.Postgres +end + +Application.put_env(:dropkick, TestRepo, + url: "ecto://postgres:postgres@localhost/dropkick", + pool: Ecto.Adapters.SQL.Sandbox, + log: false +) + +defmodule TestUser do + use Ecto.Schema + + schema "users" do + field(:name, :string) + field(:avatar, Dropkick.File) + end +end + +defmodule TestMigrationSetup do + use Ecto.Migration + + def change do + create table(:users) do + add(:name, :string) + add(:avatar, :map) + end + end +end + +_ = Ecto.Adapters.Postgres.storage_down(TestRepo.config()) + +:ok = Ecto.Adapters.Postgres.storage_up(TestRepo.config()) + +{:ok, _pid} = TestRepo.start_link() + +:ok = Ecto.Migrator.up(TestRepo, 0, TestMigrationSetup, log: false) + +Ecto.Adapters.SQL.Sandbox.mode(TestRepo, {:shared, self()}) diff --git a/test/uploader_test.exs b/test/uploader_test.exs deleted file mode 100644 index 834f5e1..0000000 --- a/test/uploader_test.exs +++ /dev/null @@ -1,59 +0,0 @@ -defmodule UploaderTest do - use ExUnit.Case - use Dropkick.FileCase - - describe "custom callbacks" do - defmodule Profile do - use Dropkick.Uploader - - def filename(%{filename: filename}), do: String.upcase(filename) - - def validate(%{content_type: "image/" <> _}, %{action: :cache}), do: :ok - def validate(%{content_type: "image/" <> _}, %{action: :store}), do: {:error, "invalid"} - end - - @tag copy: "test/fixtures/images/puppies.jpg" - test "validate", %{dir: dir, path: path} do - upload = %Plug.Upload{ - path: path, - filename: Path.basename(path), - content_type: "image/jpg" - } - - assert {:ok, %{}} = Profile.cache(upload, folder: dir) - assert {:error, "invalid"} = Profile.store(upload, folder: dir) - end - end - - describe "default callbacks" do - defmodule Avatar do - use Dropkick.Uploader - - def validate(_, _), do: :ok - end - - @tag copy: "test/fixtures/images/puppies.jpg" - test "cache", %{dir: dir, path: path} do - upload = %Plug.Upload{ - path: path, - filename: Path.basename(path), - content_type: "image/jpg" - } - - assert {:ok, %{key: key}} = Avatar.cache(upload, folder: dir) - assert File.exists?(key) - end - - @tag copy: "test/fixtures/images/puppies.jpg" - test "store", %{dir: dir, path: path} do - upload = %Plug.Upload{ - path: path, - filename: Path.basename(path), - content_type: "image/jpg" - } - - assert {:ok, %{key: key}} = Avatar.store(upload, folder: dir) - assert File.exists?(key) - end - end -end