defmodule Podbox.Download.Storage do @moduledoc """ Responsible for storing files on the local filesystem. """ defstruct [:asset, :complete_path, :complete_root, :incomplete_root, :incomplete_path, :io] alias Podbox.{Download.Asset, Download.InvalidStateError, Download.PosixError} @type t :: %__MODULE__{ asset: nil | Asset.t(), complete_path: nil | Path.t(), complete_root: nil | Path.t(), incomplete_root: nil | Path.t(), incomplete_path: nil | Path.t(), io: nil | File.io_device() } @doc """ Initialise storage for a specific asset """ @spec init(Asset.t(), Keyword.t()) :: t def init(asset, opts \\ []) do config = storage_config() |> Map.merge(Map.new(opts)) incomplete_path = generate_subpath(config.incomplete_root, asset.id) complete_path = generate_subpath(config.complete_root, asset.id) %__MODULE__{ asset: asset, complete_path: complete_path, complete_root: config.complete_root, incomplete_path: incomplete_path, incomplete_root: config.incomplete_root } end @doc """ Checks and returns the storage state. """ @spec status(t) :: :ok | :error | {:complete, non_neg_integer()} | {:incomplete, non_neg_integer()} def status(storage) do complete_exists? = File.exists?(storage.complete_path) incomplete_exists? = File.exists?(storage.incomplete_path) cond do complete_exists? && incomplete_exists? -> :error complete_exists? -> {:complete, File.stat!(storage.complete_path).size} incomplete_exists? -> {:incomplete, File.stat!(storage.incomplete_path).size} true -> :ok end end @doc """ Ensures that the directory needed to store the partial asset is present, and ready to be written to. """ @spec prepare(t) :: {:ok, t} | {:error, any} def prepare(storage) do incomplete_dir = Path.dirname(storage.incomplete_path) with :ok <- handle_posix_failure(incomplete_dir, &File.mkdir_p/1, :mkdir), {:ok, io} <- handle_posix_failure( storage.incomplete_path, &File.open(&1, [:write]), {:open, [:write]} ) do {:ok, %{storage | io: io}} end end @doc """ Write bytes to an incomplete file. """ @spec append(t, binary) :: {:ok, t} | {:error, any} def append(storage, bytes) when not is_nil(storage.io) do IO.binwrite(storage.io, bytes) {:ok, storage} end @doc """ All contents are written, now move the file to complete. """ @spec commit(t) :: {:ok, t} | {:error, any} def commit(storage) do complete_dir = Path.dirname(storage.complete_path) with :ok <- handle_posix_failure(complete_dir, &File.mkdir_p/1, :mkdir), :ok <- handle_posix_failure( storage.incomplete_path, fn _ -> File.close(storage.io) end, :close ), :ok <- handle_posix_failure( storage.incomplete_path, &File.rename(&1, storage.complete_path), {:move, to: storage.complete_path} ), :ok <- prune(storage.incomplete_path, storage.incomplete_root) do {:ok, %{storage | io: nil}} end end @doc """ Remove all artefacts from the filesystem. """ @spec destroy(t) :: :ok | {:error, any} def destroy(storage) when is_nil(storage.io) do with :ok <- prune(storage.incomplete_path, storage.incomplete_root) do prune(storage.complete_path, storage.complete_root) end end def destroy(storage) do with :ok <- File.close(storage.io) do destroy(%{storage | io: nil}) end end @doc """ Read completed file contents. """ @spec read_completed(t) :: {:ok, binary} | {:error, any} def read_completed(storage) do with :ok <- assert_complete(storage) do handle_posix_failure(storage.complete_path, &File.read/1, :read) end end defp assert_complete(storage) do case status(storage) do {:complete, _} -> :ok {:incomplete, _} -> {:error, InvalidStateError.exception( asset_id: storage.asset.id, current_state: :incomplete, expected_state: :complete )} :error -> {:error, InvalidStateError.exception( asset_id: storage.asset.id, current_state: :error, expected_state: :complete )} :ok -> {:error, InvalidStateError.exception( asset_id: storage.asset.id, current_state: :none, expected_state: :complete )} end end defp generate_subpath( root, <> = id ) do Path.join(root, [a, b, c, id]) end defp storage_config do config = :podbox |> Application.get_env(__MODULE__, []) %{ complete_root: Keyword.fetch!(config, :complete), incomplete_root: Keyword.fetch!(config, :incomplete) } end defp handle_posix_failure(path, callback, activity) when is_function(callback, 1) do with {:error, posix} <- callback.(path) do {:error, PosixError.exception(file_path: path, posix: posix, activity: activity)} end end defp prune(path, down_to) when is_binary(path) do down_to_count = down_to |> Path.split() |> Enum.count() to_prune = path |> Path.split() |> Enum.drop(down_to_count) |> Enum.reverse() prune(to_prune, down_to) end defp prune([], _down_to), do: :ok defp prune([_ | remaining] = to_prune, down_to) do path = to_prune |> Enum.reverse() |> then(&[down_to | &1]) |> Path.join() cond do File.dir?(path) -> case File.rmdir(path) do :ok -> prune(remaining, down_to) {:error, :eexist} -> :ok {:error, posix} -> {:error, PosixError.exception(file_path: path, posix: posix, activity: :delete)} end File.exists?(path) -> with :ok <- handle_posix_failure(path, &File.rm/1, :delete) do prune(remaining, down_to) end true -> prune(remaining, down_to) end end end